chore: docs 结构整改 + compose 双目录对齐(合并重建提交)

本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
This commit is contained in:
Simon
2026-08-07 22:31:32 +08:00
parent 5a77a89ab1
commit facc04aa65
573 changed files with 129347 additions and 909 deletions
@@ -0,0 +1,62 @@
# =============================================================================
# Alembic migration environment
# Handles both sync (alembic CLI) and async (app runtime) database URLs
# =============================================================================
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.config import settings
from app.database import Base
import app.models # noqa: F401
config = context.config
# Convert async URL to sync for alembic CLI operations
# aiosqlite -> sqlite, asyncpg -> psycopg2
db_url = settings.database_url
db_url = db_url.replace("+aiosqlite", "").replace("+asyncpg", "")
config.set_main_option("sqlalchemy.url", db_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Generate SQL scripts without connecting to the database."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Connect to the database and run migrations."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,47 @@
"""add media fields to messages table
为消息表添加媒体文件相关字段,支持图片、语音、文件等非文本消息。
新增字段:
- media_id: 企微媒体文件ID3天有效)
- media_url: 本地存储的媒体文件URL
- file_name: 文件名
- file_size: 文件大小(字节)
- extra_data: 扩展元数据(JSON
Revision ID: 002_media_fields
Revises: 6d5520491644
Create Date: 2026-06-03 17:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '002_media_fields'
down_revision = '6d5520491644'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加媒体文件相关字段到 messages 表。"""
# 企微媒体文件ID(图片/语音/视频消息携带,3天有效)
op.add_column('messages', sa.Column('media_id', sa.String(256), nullable=True, comment='企微媒体文件ID3天有效)'))
# 本地存储的媒体文件URL(下载后保存到服务器/NAS的访问路径)
op.add_column('messages', sa.Column('media_url', sa.String(512), nullable=True, comment='本地存储的媒体文件URL'))
# 文件名(文件消息携带)
op.add_column('messages', sa.Column('file_name', sa.String(256), nullable=True, comment='文件名'))
# 文件大小(字节)
op.add_column('messages', sa.Column('file_size', sa.Integer(), nullable=True, comment='文件大小(字节)'))
# 扩展元数据(JSON格式,存储各消息类型的额外信息)
op.add_column('messages', sa.Column('extra_data', sa.JSON(), nullable=True, comment='扩展元数据(JSON'))
def downgrade() -> None:
"""移除媒体文件相关字段。"""
op.drop_column('messages', 'extra_data')
op.drop_column('messages', 'file_size')
op.drop_column('messages', 'file_name')
op.drop_column('messages', 'media_url')
op.drop_column('messages', 'media_id')
@@ -0,0 +1,40 @@
"""add suggestion_action field to messages table
为消息表添加 suggestion_action 字段,用于追踪坐席对 AI 建议的操作行为。
取值范围:accepted(采纳)/ edited(编辑后采纳)/ ignored(忽略)
新增字段:
- suggestion_action: VARCHAR(20), nullable, 坐席对AI建议的操作行为
Revision ID: 003_suggestion_action
Revises: 002_media_fields
Create Date: 2026-07-14 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '003_suggestion_action'
down_revision = '002_media_fields'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""添加 suggestion_action 字段到 messages 表。"""
# 坐席对 AI 建议的操作行为(accepted/edited/ignored
op.add_column(
'messages',
sa.Column(
'suggestion_action',
sa.String(20),
nullable=True,
comment='AI建议操作行为: accepted/edited/ignored',
)
)
def downgrade() -> None:
"""移除 suggestion_action 字段。"""
op.drop_column('messages', 'suggestion_action')
@@ -0,0 +1,199 @@
"""initial_all_tables
Revision ID: 6d5520491644
Revises:
Create Date: 2026-06-03 17:28:43.238581
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6d5520491644'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agents',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=64), nullable=False, comment='企微用户ID(唯一)'),
sa.Column('name', sa.String(length=128), nullable=False, comment='坐席姓名'),
sa.Column('status', sa.String(length=20), nullable=False, comment='坐席状态: online/offline/busy'),
sa.Column('current_load', sa.Integer(), nullable=False, comment='当前服务会话数'),
sa.Column('max_load', sa.Integer(), nullable=False, comment='最大同时服务数'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id')
)
op.create_table('approval_links',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('category', sa.String(length=64), nullable=False, comment='分类:IT/HR/行政/财务'),
sa.Column('title', sa.String(length=128), nullable=False, comment='审批名称'),
sa.Column('url', sa.Text(), nullable=False, comment='审批链接'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_al_category', 'approval_links', ['category'], unique=False)
op.create_table('conversations',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('corp_id', sa.String(length=64), nullable=False, comment='企业微信企业ID(主企业或下游企业)'),
sa.Column('employee_id', sa.String(length=64), nullable=False, comment='企微员工UserID'),
sa.Column('employee_name', sa.String(length=128), nullable=False, comment='员工姓名'),
sa.Column('department', sa.String(length=256), nullable=False, comment='部门'),
sa.Column('position', sa.String(length=128), nullable=False, comment='岗位'),
sa.Column('level', sa.String(length=64), nullable=False, comment='等级'),
sa.Column('status', sa.String(length=20), nullable=False, comment='会话状态: ai_handling/queued/serving/resolved'),
sa.Column('is_vip', sa.Boolean(), nullable=False, comment='VIP标记'),
sa.Column('is_pinned', sa.Boolean(), nullable=False, comment='置顶标记'),
sa.Column('is_todo', sa.Boolean(), nullable=False, comment='代办标记'),
sa.Column('urgency_score', sa.Integer(), nullable=False, comment='紧急度1-5'),
sa.Column('tags', sa.JSON(), nullable=False, comment='标签集合'),
sa.Column('assigned_agent_id', sa.String(length=64), nullable=True, comment='分配的坐席ID'),
sa.Column('collaborating_agent_ids', sa.JSON(), nullable=False, comment='协作坐席ID列表'),
sa.Column('ai_substantive_reply_count', sa.Integer(), nullable=False, comment='AI实质性回复计数(满3次可呼叫坐席)'),
sa.Column('last_message_at', sa.DateTime(timezone=True), nullable=True, comment='最后消息时间'),
sa.Column('last_message_summary', sa.String(length=256), nullable=False, comment='最后消息摘要'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_conversations_assigned_agent', 'conversations', ['assigned_agent_id'], unique=False)
op.create_index('idx_conversations_corp_id', 'conversations', ['corp_id'], unique=False)
op.create_index('idx_conversations_employee_id', 'conversations', ['employee_id'], unique=False)
op.create_index('idx_conversations_last_message_at', 'conversations', ['last_message_at'], unique=False)
op.create_index('idx_conversations_status', 'conversations', ['status'], unique=False)
op.create_index('idx_conversations_urgency_score', 'conversations', ['urgency_score'], unique=False)
op.create_table('employees',
sa.Column('id', sa.String(length=36), nullable=False, comment='员工记录唯一标识'),
sa.Column('corp_id', sa.String(length=64), nullable=False, comment='企业微信企业ID'),
sa.Column('employee_id', sa.String(length=64), nullable=False, comment='企微员工UserID(企业内唯一)'),
sa.Column('name', sa.String(length=128), nullable=False, comment='员工姓名'),
sa.Column('department', sa.String(length=512), nullable=False, comment='部门ID列表(JSON数组)'),
sa.Column('position', sa.String(length=128), nullable=False, comment='岗位'),
sa.Column('mobile', sa.String(length=32), nullable=False, comment='手机号'),
sa.Column('email', sa.String(length=128), nullable=False, comment='邮箱'),
sa.Column('avatar', sa.String(length=512), nullable=False, comment='头像URL'),
sa.Column('status', sa.Integer(), nullable=False, comment='激活状态: 1=已激活, 2=已禁用, 4=未激活'),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True, comment='最后登录时间'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('corp_id', 'employee_id', name='uq_employee_corp')
)
op.create_index('idx_employees_corp_id', 'employees', ['corp_id'], unique=False)
op.create_index('idx_employees_employee_id', 'employees', ['employee_id'], unique=False)
op.create_table('funny_phrases',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('scene', sa.String(length=64), nullable=False, comment='触发场景: shake/keyword/waiting/connected/timeout/vip'),
sa.Column('content', sa.Text(), nullable=False, comment='话术内容'),
sa.Column('tone', sa.String(length=32), nullable=False, comment='语气标签'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('is_active', sa.Boolean(), nullable=False, comment='是否启用'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_fp_scene', 'funny_phrases', ['scene'], unique=False)
op.create_table('quick_reply_templates',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('category', sa.String(length=64), nullable=False, comment='分类:账号/网络/软件/硬件/通用'),
sa.Column('title', sa.String(length=128), nullable=False, comment='模板标题'),
sa.Column('content', sa.Text(), nullable=False, comment='模板内容,支持变量如 {employee_name}'),
sa.Column('variables', sa.JSON(), nullable=False, comment='可用变量列表'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_qr_category', 'quick_reply_templates', ['category'], unique=False)
op.create_table('software_downloads',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('category', sa.String(length=64), nullable=False, comment='分类:办公/开发/安全/工具'),
sa.Column('name', sa.String(length=128), nullable=False, comment='软件名称'),
sa.Column('version', sa.String(length=32), nullable=False, comment='版本号'),
sa.Column('platform', sa.String(length=32), nullable=False, comment='平台: Windows/Mac/Linux/全平台'),
sa.Column('download_url', sa.Text(), nullable=False, comment='下载链接'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_sd_category', 'software_downloads', ['category'], unique=False)
op.create_table('system_configs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('config_key', sa.String(length=128), nullable=False, comment='配置键'),
sa.Column('config_value', sa.Text(), nullable=False, comment='配置值(JSON字符串或纯文本)'),
sa.Column('description', sa.String(length=256), nullable=False, comment='配置说明'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('config_key')
)
op.create_table('agent_notes',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('conversation_id', sa.String(length=36), nullable=False, comment='所属会话ID'),
sa.Column('agent_id', sa.String(length=64), nullable=False, comment='坐席ID'),
sa.Column('content', sa.Text(), nullable=False, comment='备注内容'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_an_conversation', 'agent_notes', ['conversation_id'], unique=False)
op.create_table('messages',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('conversation_id', sa.String(length=36), nullable=False, comment='所属会话ID'),
sa.Column('sender_type', sa.String(length=20), nullable=False, comment='发送者类型: employee/agent/ai/system'),
sa.Column('sender_id', sa.String(length=64), nullable=False, comment='发送者ID'),
sa.Column('sender_name', sa.String(length=128), nullable=False, comment='发送者姓名'),
sa.Column('content', sa.Text(), nullable=False, comment='消息内容'),
sa.Column('msg_type', sa.String(length=20), nullable=False, comment='消息类型: text/image/file/system'),
sa.Column('ai_suggestion', sa.Boolean(), nullable=False, comment='是否为AI建议'),
sa.Column('is_read', sa.Boolean(), nullable=False, comment='是否已读'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_messages_conversation_created', 'messages', ['conversation_id', 'created_at'], unique=False)
op.create_index('idx_messages_conversation_id', 'messages', ['conversation_id'], unique=False)
op.create_index('idx_messages_created_at', 'messages', ['created_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_messages_created_at', table_name='messages')
op.drop_index('idx_messages_conversation_id', table_name='messages')
op.drop_index('idx_messages_conversation_created', table_name='messages')
op.drop_table('messages')
op.drop_index('idx_an_conversation', table_name='agent_notes')
op.drop_table('agent_notes')
op.drop_table('system_configs')
op.drop_index('idx_sd_category', table_name='software_downloads')
op.drop_table('software_downloads')
op.drop_index('idx_qr_category', table_name='quick_reply_templates')
op.drop_table('quick_reply_templates')
op.drop_index('idx_fp_scene', table_name='funny_phrases')
op.drop_table('funny_phrases')
op.drop_index('idx_employees_employee_id', table_name='employees')
op.drop_index('idx_employees_corp_id', table_name='employees')
op.drop_table('employees')
op.drop_index('idx_conversations_urgency_score', table_name='conversations')
op.drop_index('idx_conversations_status', table_name='conversations')
op.drop_index('idx_conversations_last_message_at', table_name='conversations')
op.drop_index('idx_conversations_employee_id', table_name='conversations')
op.drop_index('idx_conversations_corp_id', table_name='conversations')
op.drop_index('idx_conversations_assigned_agent', table_name='conversations')
op.drop_table('conversations')
op.drop_index('idx_al_category', table_name='approval_links')
op.drop_table('approval_links')
op.drop_table('agents')
# ### end Alembic commands ###