fix(user): 回填本地账号 external_id,修复 admin 密码登录失败 (#1985)

commit 94323e3ec 把登录查询从 user_name 切到 external_id,但未对已有本地 账号做数据迁移——源于
admin 及所有旧本地用户的 external_id=NULL,密码
登录一律返回 10600 "Account or password error"。

- Alembic f012: 为 source='local' 且 external_id IS NULL 的活跃用户回填
external_id = user_name。uk_user_source_external_id(source,external_id)
复合唯一键天然继承原 user_name 唯一性语义。
- UserService.create_user: 新建本地账号时自动把 external_id 填成 user_name, 堵住 UI
新建用户后无法登录的漏洞(SSO 路径走 org_sync,不经此函数)。
This commit is contained in:
芥子观须弥
2026-04-19 00:06:26 -07:00
committed by GitHub
2 changed files with 74 additions and 0 deletions
@@ -0,0 +1,69 @@
"""F012: Backfill user.external_id for local accounts.
Revision ID: f012_backfill_local_external_id
Revises: f011_backfill_create_knowledge_web_menu
Create Date: 2026-04-19
Background:
Commit 94323e3ec (2026-04-17) changed the password-login query from
``User.user_name == account`` to ``User.external_id == account`` — aligned
with F009's org-sync model where external_id is the stable identity — but
did not ship a data migration for pre-existing local accounts. As a result,
every row with ``source='local' AND external_id IS NULL`` (including the
bootstrap admin) becomes unable to log in via password.
This migration backfills ``external_id = user_name`` for local accounts,
restoring password login. The ``uk_user_source_external_id`` unique
constraint on ``(source, external_id)`` naturally inherits user_name's
former uniqueness within ``source='local'``.
Scope:
- Only ``source='local'`` and ``delete=0`` rows are touched.
- Rows that already have a non-NULL external_id are left alone (idempotent).
- SSO-synced rows (source='feishu'/'wecom'/...) are untouched — their
external_id is the authoritative external employee ID.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = 'f012_backfill_local_external_id'
down_revision: Union[str, Sequence[str], None] = 'f011_backfill_create_knowledge_web_menu'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
result = conn.execute(
sa.text(
'SELECT COUNT(*) FROM information_schema.TABLES '
'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t',
),
{'t': table_name},
)
return result.scalar() > 0
def upgrade() -> None:
if not _table_exists('user'):
return
op.execute(
sa.text(
"""
UPDATE `user`
SET external_id = user_name
WHERE external_id IS NULL
AND source = 'local'
AND `delete` = 0
"""
)
)
def downgrade() -> None:
# Intentional no-op: clearing external_id would re-break login for users
# who have since logged in or been referenced by newer flows.
pass
@@ -118,6 +118,11 @@ class UserService:
user = User(
user_name=req_data.user_name,
password=cls.decrypt_md5_password(req_data.password),
source='local',
# Default external_id to user_name so password login (which queries
# external_id only since 94323e3ec) works out of the box. SSO-synced
# users set their own external_id via org_sync, not through here.
external_id=req_data.user_name,
)
group_ids = []
role_ids = []