Files
yunke/yunke-server/migrations/versions/d7a9bc760c6b_initial_schema.py
T
浅念 9cf1c9ab11 feat: core ORM models + initial migration
Implements the 14 core ORM models for the yunke data-mirror system using
SQLAlchemy 2.0 Mapped/mapped_column style, driven by the field lists in
docs/superpowers/specs/2026-08-01-yunke-data-dictionary.md:

  Department, Staff, WechatAccount, WhatsappAccount, Customer, ChatGroup,
  GroupMember, Message, Media, PhoneCall, Sms, SyncState, YkAccount, SyncRun.

Conventions:
- Natural source PKs where they exist (friend_id, wx_id, msg_id, room_id,
  media_hash, call_id, sms_id, dept_id, user_id); surrogate Integer PKs
  otherwise (SyncState, YkAccount, SyncRun). GroupMember uses a composite
  (channel, room_id, member_id) PK.
- Common audit columns version/raw_json/fetched_at/updated_at on every
  source-mirrored table.
- channel only where meaningful: Customer/Message/Media/ChatGroup/
  GroupMember/Sms use messaging channels; PhoneCall.channel is call-type;
  SyncState.channel scopes the cursor. Omitted on Staff/Department/
  WechatAccount/WhatsappAccount/YkAccount/SyncRun.
- Message composite index on (channel, owner_account_id, talker_id,
  timestamp) for the hot per-talker time-order query path.
- message_table_name(channel, timestamp) helper documents the future
  message_<channel>_<YYYYMM> sharding convention; the Message model still
  maps to a single physical table for now (sharding is a later task).

All types are portable generic SQLAlchemy types so tests run on in-memory
SQLite while prod targets MySQL. Tests verify Customer round-trip,
raw_json JSON round-trip, Message natural PK, GroupMember composite PK,
the Message composite index, and full table creation on SQLite.

Alembic wired via migrations/env.py to Base.metadata + get_settings().db_url
with an async->sync driver swap (mysql+asyncmy -> mysql+pymysql,
sqlite+aiosqlite -> sqlite). Initial migration autogenerated against an
empty SQLite DB; server defaults normalized to sa.func.now() so the file
is MySQL-portable. upgrade head is intentionally not run (no MySQL yet).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:15:34 +08:00

332 lines
19 KiB
Python

"""initial schema
Revision ID: d7a9bc760c6b
Revises:
Create Date: 2026-08-01 11:11:18.566666
Autogenerated against an empty SQLite database (no MySQL server was available
during development). All column types are generic (``sa.String``, ``sa.Text``,
``sa.JSON``, ``sa.BigInteger``, ``sa.Integer``, ``sa.Boolean``, ``sa.DateTime``)
and the ``server_default=sa.func.now()`` timestamps compile per-dialect, so the
migration is portable to MySQL. When a MySQL instance is provisioned, ``alembic
upgrade head`` should be run there directly (no regeneration needed).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'd7a9bc760c6b'
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chat_group',
sa.Column('room_id', sa.String(length=128), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=False),
sa.Column('room_name', sa.String(length=255), nullable=True),
sa.Column('owner_account_id', sa.String(length=128), nullable=True),
sa.Column('owner_account_nickname', sa.String(length=255), nullable=True),
sa.Column('member_count', sa.Integer(), nullable=True),
sa.Column('member_ids', sa.Text(), nullable=True),
sa.Column('head_image_url', sa.String(length=1024), nullable=True),
sa.Column('owner_user_id', sa.String(length=64), nullable=True),
sa.Column('owner_user_phone', sa.String(length=64), nullable=True),
sa.Column('message_count', sa.Integer(), nullable=True),
sa.Column('own_message_count', sa.Integer(), nullable=True),
sa.Column('loss_members', sa.Integer(), nullable=True),
sa.Column('add_members', sa.Integer(), nullable=True),
sa.Column('last_chat_time', sa.BigInteger(), nullable=True),
sa.Column('update_time', sa.BigInteger(), nullable=True),
sa.Column('version', sa.String(length=64), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('room_id')
)
op.create_index('ix_chat_group_owner', 'chat_group', ['channel', 'owner_account_id', 'room_id'], unique=False)
op.create_table('customer',
sa.Column('friend_id', sa.String(length=128), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=False),
sa.Column('owner_account_id', sa.String(length=128), nullable=True),
sa.Column('friend_nickname', sa.String(length=255), nullable=True),
sa.Column('friend_remark', sa.String(length=255), nullable=True),
sa.Column('real_name', sa.String(length=255), nullable=True),
sa.Column('phone', sa.String(length=64), nullable=True),
sa.Column('wechat_phone', sa.String(length=64), nullable=True),
sa.Column('gender', sa.Integer(), nullable=True),
sa.Column('region', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('sale_name', sa.String(length=255), nullable=True),
sa.Column('user_id', sa.String(length=64), nullable=True),
sa.Column('dept_route', sa.String(length=255), nullable=True),
sa.Column('dept_name', sa.String(length=255), nullable=True),
sa.Column('head_image_url', sa.String(length=1024), nullable=True),
sa.Column('last_chat_time', sa.BigInteger(), nullable=True),
sa.Column('send_num', sa.Integer(), nullable=True),
sa.Column('receive_num', sa.Integer(), nullable=True),
sa.Column('message_count', sa.Integer(), nullable=True),
sa.Column('delete_flag', sa.Integer(), nullable=True),
sa.Column('is_block', sa.Boolean(), nullable=True),
sa.Column('friend_state', sa.Integer(), nullable=True),
sa.Column('version', sa.String(length=64), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('friend_id')
)
op.create_index('ix_customer_owner_friend', 'customer', ['channel', 'owner_account_id', 'friend_id'], unique=False)
op.create_table('department',
sa.Column('dept_id', sa.String(length=64), nullable=False),
sa.Column('parent_id', sa.String(length=64), nullable=True),
sa.Column('name', sa.String(length=255), nullable=True),
sa.Column('path', sa.String(length=512), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('dept_id')
)
op.create_table('group_member',
sa.Column('channel', sa.String(length=16), nullable=False),
sa.Column('room_id', sa.String(length=128), nullable=False),
sa.Column('member_id', sa.String(length=128), nullable=False),
sa.Column('nickname', sa.String(length=255), nullable=True),
sa.Column('avatar', sa.String(length=1024), nullable=True),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('channel', 'room_id', 'member_id')
)
op.create_table('media',
sa.Column('media_hash', sa.String(length=64), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=False),
sa.Column('media_type', sa.String(length=32), nullable=True),
sa.Column('original_url', sa.String(length=1024), nullable=True),
sa.Column('original_th_url', sa.String(length=1024), nullable=True),
sa.Column('original_hd_url', sa.String(length=1024), nullable=True),
sa.Column('local_path', sa.String(length=1024), nullable=True),
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
sa.Column('mime_type', sa.String(length=128), nullable=True),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('media_hash')
)
op.create_index('ix_media_type_channel', 'media', ['media_type', 'channel'], unique=False)
op.create_table('message',
sa.Column('msg_id', sa.String(length=64), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=False),
sa.Column('owner_account_id', sa.String(length=128), nullable=True),
sa.Column('talker_id', sa.String(length=128), nullable=True),
sa.Column('ori_talker', sa.String(length=128), nullable=True),
sa.Column('room_id', sa.String(length=128), nullable=True),
sa.Column('is_send', sa.Boolean(), nullable=True),
sa.Column('type', sa.Integer(), nullable=True),
sa.Column('text', sa.Text(), nullable=True),
sa.Column('file_url', sa.String(length=1024), nullable=True),
sa.Column('file_th_url', sa.String(length=1024), nullable=True),
sa.Column('file_hd_url', sa.String(length=1024), nullable=True),
sa.Column('md5', sa.String(length=64), nullable=True),
sa.Column('md5_th', sa.String(length=64), nullable=True),
sa.Column('md5_hd', sa.String(length=64), nullable=True),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('transcription_text', sa.Text(), nullable=True),
sa.Column('img_path', sa.String(length=1024), nullable=True),
sa.Column('original_image', sa.String(length=1024), nullable=True),
sa.Column('pic_type', sa.Integer(), nullable=True),
sa.Column('pay_msg_id', sa.String(length=64), nullable=True),
sa.Column('refer_msg', sa.JSON(), nullable=True),
sa.Column('sender_name', sa.String(length=255), nullable=True),
sa.Column('sender_head_url', sa.String(length=1024), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.Column('is_del', sa.Boolean(), nullable=True),
sa.Column('version', sa.String(length=64), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('msg_id')
)
op.create_index('ix_message_channel_owner_talker_time', 'message', ['channel', 'owner_account_id', 'talker_id', 'timestamp'], unique=False)
op.create_table('phone_call',
sa.Column('call_id', sa.String(length=64), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=False),
sa.Column('start_call_time', sa.BigInteger(), nullable=True),
sa.Column('call_duration', sa.Integer(), nullable=True),
sa.Column('ring_time', sa.Integer(), nullable=True),
sa.Column('call_status', sa.Integer(), nullable=True),
sa.Column('incoming_call', sa.Integer(), nullable=True),
sa.Column('user_id', sa.String(length=64), nullable=True),
sa.Column('user_id_name', sa.String(length=255), nullable=True),
sa.Column('department_id', sa.String(length=64), nullable=True),
sa.Column('call_number', sa.String(length=64), nullable=True),
sa.Column('sim_phone', sa.String(length=64), nullable=True),
sa.Column('sim_number', sa.String(length=64), nullable=True),
sa.Column('imei', sa.String(length=128), nullable=True),
sa.Column('record_file', sa.String(length=1024), nullable=True),
sa.Column('is_upload', sa.Boolean(), nullable=True),
sa.Column('download_count', sa.Integer(), nullable=True),
sa.Column('ai_asr', sa.Text(), nullable=True),
sa.Column('qc_status', sa.Integer(), nullable=True),
sa.Column('plan_customer_name', sa.String(length=255), nullable=True),
sa.Column('version', sa.String(length=64), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('call_id')
)
op.create_index('ix_phone_call_user_time', 'phone_call', ['user_id', 'start_call_time'], unique=False)
op.create_table('sms',
sa.Column('sms_id', sa.String(length=64), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=False),
sa.Column('customer_phone', sa.String(length=64), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('content_preview', sa.String(length=255), nullable=True),
sa.Column('sender', sa.String(length=128), nullable=True),
sa.Column('direction', sa.Integer(), nullable=True),
sa.Column('user_id', sa.String(length=64), nullable=True),
sa.Column('sender_department', sa.String(length=255), nullable=True),
sa.Column('sender_department_id', sa.String(length=64), nullable=True),
sa.Column('receive_name', sa.String(length=255), nullable=True),
sa.Column('create_time', sa.BigInteger(), nullable=True),
sa.Column('sim_phone', sa.String(length=64), nullable=True),
sa.Column('sim_number', sa.String(length=64), nullable=True),
sa.Column('source', sa.String(length=64), nullable=True),
sa.Column('version', sa.String(length=64), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('sms_id')
)
op.create_index('ix_sms_user_time', 'sms', ['user_id', 'create_time'], unique=False)
op.create_table('staff',
sa.Column('user_id', sa.String(length=64), nullable=False),
sa.Column('name', sa.String(length=255), nullable=True),
sa.Column('phone', sa.String(length=64), nullable=True),
sa.Column('sale_phone', sa.String(length=64), nullable=True),
sa.Column('dept_id', sa.String(length=64), nullable=True),
sa.Column('is_on_job', sa.Boolean(), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('user_id')
)
op.create_table('sync_run',
sa.Column('run_id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('entity_type', sa.String(length=32), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=True),
sa.Column('scope_id', sa.String(length=128), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('started_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('finished_at', sa.DateTime(), nullable=True),
sa.Column('records_fetched', sa.Integer(), server_default=sa.text('0'), nullable=False),
sa.Column('records_upserted', sa.Integer(), server_default=sa.text('0'), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('run_id')
)
op.create_index('ix_sync_run_entity_status', 'sync_run', ['entity_type', 'status'], unique=False)
op.create_table('sync_state',
sa.Column('state_id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('entity_type', sa.String(length=32), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=True),
sa.Column('scope_id', sa.String(length=128), nullable=True),
sa.Column('last_version', sa.String(length=64), nullable=True),
sa.Column('last_timestamp', sa.BigInteger(), nullable=True),
sa.Column('last_synced_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('state_id')
)
op.create_index('ix_sync_state_scope', 'sync_state', ['entity_type', 'channel', 'scope_id'], unique=True)
op.create_table('wechat_account',
sa.Column('wx_id', sa.String(length=128), nullable=False),
sa.Column('nick_name', sa.String(length=255), nullable=True),
sa.Column('alias', sa.String(length=255), nullable=True),
sa.Column('phone', sa.String(length=64), nullable=True),
sa.Column('user_id', sa.String(length=64), nullable=True),
sa.Column('user_name', sa.String(length=255), nullable=True),
sa.Column('contacts_num', sa.Integer(), nullable=True),
sa.Column('group_num', sa.Integer(), nullable=True),
sa.Column('send_num', sa.Integer(), nullable=True),
sa.Column('receive_num', sa.Integer(), nullable=True),
sa.Column('is_on_job', sa.Boolean(), nullable=True),
sa.Column('db_size', sa.Integer(), nullable=True),
sa.Column('online_status', sa.String(length=32), nullable=True),
sa.Column('last_update', sa.BigInteger(), nullable=True),
sa.Column('version', sa.String(length=64), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('wx_id')
)
op.create_table('whatsapp_account',
sa.Column('uid', sa.String(length=128), nullable=False),
sa.Column('user_id', sa.String(length=64), nullable=True),
sa.Column('user_name', sa.String(length=255), nullable=True),
sa.Column('nick_name', sa.String(length=255), nullable=True),
sa.Column('phone', sa.String(length=64), nullable=True),
sa.Column('sale_phone', sa.String(length=64), nullable=True),
sa.Column('sales_bind_phone', sa.String(length=64), nullable=True),
sa.Column('contacts_num', sa.Integer(), nullable=True),
sa.Column('group_num', sa.Integer(), nullable=True),
sa.Column('talker_count', sa.Integer(), nullable=True),
sa.Column('friend_add_num', sa.Integer(), nullable=True),
sa.Column('is_on_job', sa.Boolean(), nullable=True),
sa.Column('last_update', sa.BigInteger(), nullable=True),
sa.Column('version', sa.String(length=64), nullable=True),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('uid')
)
op.create_table('yk_account',
sa.Column('account_id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.String(length=64), nullable=False),
sa.Column('company', sa.String(length=64), nullable=False),
sa.Column('username', sa.String(length=255), nullable=True),
sa.Column('session_blob_enc', sa.Text(), nullable=True),
sa.Column('last_login_at', sa.DateTime(), nullable=True),
sa.Column('is_active', sa.Boolean(), server_default=sa.text('1'), nullable=False),
sa.Column('raw_json', sa.JSON(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('account_id')
)
op.create_index('ix_yk_account_user_company', 'yk_account', ['user_id', 'company'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_yk_account_user_company', table_name='yk_account')
op.drop_table('yk_account')
op.drop_table('whatsapp_account')
op.drop_table('wechat_account')
op.drop_index('ix_sync_state_scope', table_name='sync_state')
op.drop_table('sync_state')
op.drop_index('ix_sync_run_entity_status', table_name='sync_run')
op.drop_table('sync_run')
op.drop_table('staff')
op.drop_index('ix_sms_user_time', table_name='sms')
op.drop_table('sms')
op.drop_index('ix_phone_call_user_time', table_name='phone_call')
op.drop_table('phone_call')
op.drop_index('ix_message_channel_owner_talker_time', table_name='message')
op.drop_table('message')
op.drop_index('ix_media_type_channel', table_name='media')
op.drop_table('media')
op.drop_table('group_member')
op.drop_table('department')
op.drop_index('ix_customer_owner_friend', table_name='customer')
op.drop_table('customer')
op.drop_index('ix_chat_group_owner', table_name='chat_group')
op.drop_table('chat_group')
# ### end Alembic commands ###