diff --git a/alembic/versions/0014_drop_user_identities.py b/alembic/versions/0014_drop_user_identities.py new file mode 100644 index 0000000..9e8cb80 --- /dev/null +++ b/alembic/versions/0014_drop_user_identities.py @@ -0,0 +1,39 @@ +"""去多身份:drop user_identities + +Revision ID: 0014_drop_user_identities +Revises: 0013_opc_cert_park_transfer +Create Date: 2026-08-26 +""" +from __future__ import annotations + +from alembic import op + +revision = "0014_drop_user_identities" +down_revision = "0013_opc_cert_park_transfer" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 账号单一角色(去多身份/多端口):user_identities 不再使用,删除表。 + op.drop_table("user_identities") + + +def downgrade() -> None: + # 降级:重建 user_identities(结构由模型决定,这里按原 models 恢复)。 + import sqlalchemy as sa + + op.create_table( + "user_identities", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("user_id", sa.String(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("port", sa.String(), default="opc"), + sa.Column("role", sa.String(), default="opc_member"), + sa.Column("sub_role", sa.String(), nullable=True), + sa.Column("name", sa.String(), default=""), + sa.Column("org_id", sa.String(), sa.ForeignKey("organizations.id"), nullable=True), + sa.Column("region_id", sa.String(), sa.ForeignKey("regions.id"), nullable=True), + sa.Column("status", sa.String(), default="active"), + sa.Column("created_at", sa.String(), default=""), + sa.Column("updated_at", sa.String(), default=""), + ) diff --git a/app/api/dependencies.py b/app/api/dependencies.py index 10b5707..772f6c7 100644 --- a/app/api/dependencies.py +++ b/app/api/dependencies.py @@ -83,11 +83,14 @@ async def optional_current_user( user = await db.users.get_by_id(payload.get("sub")) if user is None or user.get("status") != "active": return None - return await _resolve_identity(user, payload.get("identity_id"), db) + return await _resolve_identity(user, db) def require_port(user: dict = Depends(get_current_user)) -> dict: - """按端口隔离的资源(如智能体):必须已解析出端口身份,否则 403。""" - if not user.get("port"): - raise HTTPException(status_code=403, detail="请先选择身份/端口") + """按端口隔离的资源(如智能体)—— 单角色后不再按端口隔离,仅要求已登录。 + + 账号唯一角色:智能体不再按端口隔离,统一归到该账号(port="")。为兼容 + 既有 AgentRepository 的 port 过滤,这里补一个恒定空串。 + """ + user.setdefault("port", "") return user diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 59e6495..6bf05b6 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -19,14 +19,12 @@ from ... import config from ..dependencies import get_current_user, get_db, extract_bearer_token from ..schemas.auth import ( AuthStatusResponse, - IdentityInfo, LoginRequest, LoginResponse, PhoneLoginRequest, ProfileResponse, RegisterRequest, RevokeTokenRequest, - SelectIdentityRequest, SendCodeRequest, SendCodeResponse, UpdateProfileRequest, @@ -111,19 +109,6 @@ def _is_phone(account: str) -> bool: _PROFILE_FIELDS = ("nickname", "account", "room", "avatar", "company_avatar", "email", "gender", "birthday") -def _identity_summaries(identities: list[dict]) -> list[IdentityInfo]: - """把 user_identities 记录裁剪成对外身份摘要。""" - return [ - IdentityInfo( - id=i["id"], port=i["port"], role=i["role"], sub_role=i.get("sub_role"), - name=i.get("name", ""), org_id=i.get("org_id"), region_id=i.get("region_id"), - org_name=i.get("org_name"), region_name=i.get("region_name"), - status=i.get("status", "active"), - ) - for i in identities - ] - - async def _issue_token( db: Database, user: dict, @@ -236,22 +221,6 @@ async def login(req: LoginRequest, db: Database = Depends(get_db)): ) -@router.post("/select-identity", response_model=LoginResponse, summary="切换端口身份") -async def select_identity( - req: SelectIdentityRequest, - user: dict = Depends(get_current_user), - db: Database = Depends(get_db), -): - """切换身份(多身份已收敛)——恒按账号单一角色(users.role)签发,忽略 identity_id。""" - token_record = await _issue_token(db, user, None, identity=None) - profile = await _profile_for(user, None, db) - return LoginResponse( - token=token_record["token"], - identities=[], - **profile, - ) - - @router.post("/register", response_model=LoginResponse, summary="注册") async def register(req: RegisterRequest, request: Request, db: Database = Depends(get_db)): """注册唯一账户(演示端已存在 pine,故返回 403)。""" @@ -275,15 +244,11 @@ async def register(req: RegisterRequest, request: Request, db: Database = Depend source=_client_source(request, getattr(req, "source", ""), "password"), auth_type="password", register_ip=_client_ip(request), ) - identity = await db.identities.create( - user["id"], port="opc", role="opc_member", - sub_role="independent", name="独立OPC", - ) - token_record = await _issue_token(db, user, req.expires_in, identity=identity) - profile = await _profile_for(user, identity, db) + token_record = await _issue_token(db, user, req.expires_in, identity=None) + profile = await _profile_for(user, None, db) return LoginResponse( token=token_record["token"], - identities=_identity_summaries([identity]), + identities=[], **profile, ) @@ -431,44 +396,23 @@ async def revoke_all_sessions( # =========================================================================== -async def _ensure_opc_identity(db: Database, user_id: str) -> dict: - """确保账号拥有 opc_member 身份(登录即建;重复登录复用已有)。""" - for ident in await db.identities.list_for_user(user_id, active_only=True): - if ident["port"] == "opc" and ident["role"] == "opc_member": - return ident - return await db.identities.create( - user_id, port="opc", role="opc_member", - sub_role="independent", name="独立OPC", - ) - - -async def _login_response( - db: Database, user: dict, identity: dict, identities: list[dict] | None = None, -) -> LoginResponse: - """以指定身份签发令牌并装配登录响应(与密码登录同构)。""" - token_record = await _issue_token(db, user, None, identity=identity) - profile = await _profile_for(user, identity, db) - return LoginResponse( - token=token_record["token"], - identities=_identity_summaries(identities or [identity]), - **profile, - ) +async def _ensure_opc_identity(db: Database, user_id: str) -> None: + """占位(去多身份/单角色):opc_member 身份已收敛为账号唯一角色,无需再建身份。""" + return None async def _login_response_for_user(db: Database, user: dict) -> LoginResponse: - """统一登录响应:令牌角色由**账号真实身份**决定,而非强制 opc_member。 + """统一登录响应:令牌角色 = 账号单一角色(users.role → account_type)。 - 保证同一账号无论用 手机号/微信/小程序 哪个方式登录,拿到的角色与权限都一致 - (= 用账号密码登录的结果)。规则: - - 先 `_ensure_opc_identity` 确保 C 端也有 opc_member 身份(报名/政策等可用); - - 用 `list_for_user` 的真实身份集合决定令牌角色: - 多身份(如 operator+opc_member)→ 中性令牌,role 取 users.role(=operator); - 仅 opc_member → opc 令牌。 + 同一账号无论 手机号/微信/小程序 哪个方式登录,角色与权限均一致(= 账号密码登录结果)。 """ - await _ensure_opc_identity(db, user["id"]) - identities = await db.identities.list_for_user(user["id"], active_only=True) - token_identity = identities[0] if len(identities) == 1 else None - return await _login_response(db, user, token_identity, identities) + token_record = await _issue_token(db, user, None, identity=None) + profile = await _profile_for(user, None, db) + return LoginResponse( + token=token_record["token"], + identities=[], + **profile, + ) @router.post("/send-code", response_model=SendCodeResponse, summary="发送短信验证码") diff --git a/app/api/routers/rbac_developer.py b/app/api/routers/rbac_developer.py deleted file mode 100644 index 29d74d5..0000000 --- a/app/api/routers/rbac_developer.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -"""开放平台端点:开发者工作台 / 应用 / 插件 / 技能 / API 凭证。""" -from __future__ import annotations - -from fastapi import APIRouter, Depends - -from ..dependencies import get_db -from ...rbac import require_roles -from ...infrastructure.repositories import Database - -router = APIRouter(prefix="/developer", tags=["developer"]) - - -async def _page(port: str, page: str): - async def handler(db: Database = Depends(get_db)): - return await db.portal_pages.get(port, page) or {"items": []} - - return handler - - -@router.get("/dashboard", summary="开发者工作台") -async def dev_dashboard(db: Database = Depends(get_db), _u: dict = Depends(require_roles("service"))): - return await db.portal_pages.get("developer", "dashboard") or {"stats": []} - - -@router.get("/apps", summary="我的应用") -async def dev_apps(db: Database = Depends(get_db), _u: dict = Depends(require_roles("service"))): - return await db.portal_pages.get("developer", "apps") or {"items": []} - - -@router.get("/plugins", summary="我的插件") -async def dev_plugins(db: Database = Depends(get_db), _u: dict = Depends(require_roles("service"))): - return await db.portal_pages.get("developer", "plugins") or {"items": []} - - -@router.get("/skills", summary="技能市场") -async def dev_skills(db: Database = Depends(get_db), _u: dict = Depends(require_roles("service"))): - return await db.portal_pages.get("developer", "skills") or {"items": []} - - -@router.get("/api-keys", summary="API 凭证") -async def dev_api_keys(db: Database = Depends(get_db), _u: dict = Depends(require_roles("service"))): - return {"items": [ - {"id": "key_001", "name": "生产环境", "prefix": "pa_live_****abcd", "created_at": "2026-07-01"}, - {"id": "key_002", "name": "测试环境", "prefix": "pa_test_****wxyz", "created_at": "2026-07-05"}, - ], "note": "演示:凭证仅展示脱敏前缀"} diff --git a/app/api/routers/rbac_government.py b/app/api/routers/rbac_government.py deleted file mode 100644 index d790aef..0000000 --- a/app/api/routers/rbac_government.py +++ /dev/null @@ -1,149 +0,0 @@ -# -*- coding: utf-8 -*- -"""政务端端点(证明数据范围层级:上级可看下级,下级不可看上级)。""" -from __future__ import annotations - -from fastapi import APIRouter, Depends, HTTPException, Request - -from ..dependencies import get_db -from ...rbac import require_permission, require_scope, write_audit -from ...infrastructure.repositories import Database - -router = APIRouter(prefix="/government", tags=["government"]) - - -@router.get("/regions", summary="可见区域(按数据范围)") -async def list_regions( - db: Database = Depends(get_db), - user: dict = Depends(require_scope()), -): - """返回当前政务账号数据范围内的区域(省级=全部,区县=仅本区县)。""" - all_regions = await db.regions.all() - visible = {rid for rid in user.get("scope_region_ids", [])} - return [r for r in all_regions if r["id"] in visible] - - -@router.get("/enterprises", summary="区域内企业列表") -async def list_enterprises( - request: Request, - db: Database = Depends(get_db), - user: dict = Depends(require_permission("menu:gov_data")), -): - """返回数据范围内的甲方企业;每次访问写一条 data.view 审计。""" - orgs = await db.orgs.list_by_region(user.get("scope_region_ids", [])) - enterprises = [o for o in orgs if o["type"] == "enterprise"] - await write_audit( - db, action="data.view", resource="enterprise", - detail=f"scope={user.get('scope_region_ids', [])}", - user=user, request=request, - ) - return enterprises - - -@router.get("/enterprises/{org_id}", summary="企业详情(范围校验)") -async def get_enterprise( - org_id: str, - request: Request, - db: Database = Depends(get_db), - user: dict = Depends(require_permission("menu:gov_data")), -): - org = await db.orgs.get(org_id) - if org is None or org["type"] != "enterprise": - raise HTTPException(status_code=404, detail="Enterprise not found") - if org["region_id"] not in user.get("scope_region_ids", []): - raise HTTPException(status_code=403, detail="Forbidden: out of data scope") - return org - - -# ── 三级数据范围:范围内 OPC / 载体 / 数据看板 ──────────────────────────── - -def _in_scope(region_id, user) -> bool: - return region_id in user.get("scope_region_ids", []) - - -@router.get("/opc", summary="范围内 OPC 列表") -async def gov_opc( - db: Database = Depends(get_db), - user: dict = Depends(require_permission("menu:gov_data")), -): - opcs = [u for u in await db.users.list() if u.get("role") == "opc_member"] - opcs = [u for u in opcs if _in_scope(u.get("region_id"), user)] - return {"items": [ - {"title": u.get("nickname") or u.get("username"), "meta": f"信用 {u.get('region_id', '')} · OPC", - "tag": "OPC"} - for u in opcs - ]} - - -@router.get("/carriers", summary="范围内载体列表") -async def gov_carriers( - db: Database = Depends(get_db), - user: dict = Depends(require_permission("menu:gov_data")), -): - carriers = [o for o in await db.orgs.all() if o["type"] == "carrier"] - carriers = [o for o in carriers if _in_scope(o.get("region_id"), user)] - return {"items": [ - {"title": o["name"], "meta": f"{o.get('region_id', '')} · 载体", "tag": "载体"} - for o in carriers - ]} - - -@router.get("/data", summary="范围内数据看板") -async def gov_data( - db: Database = Depends(get_db), - user: dict = Depends(require_permission("menu:gov_data")), -): - scope = user.get("scope_region_ids", []) - stats = await db.stats.overview(region_ids=scope) - return { - "stats": [ - {"key": "totalOPC", "value": stats["user_count"]}, - {"key": "taskTotal", "value": stats["task_count"]}, - {"key": "carrierTotal", "value": stats["provider_count"]}, - {"key": "policyTotal", "value": stats["content_count"]}, - ], - "chart": [ - {"label": "6月", "value": 3200}, {"label": "7月", "value": 3600}, - {"label": "8月", "value": 4100}, - ], - "scope": scope, - } - - -# ── 补贴三级审批(区县→市→省→发放)───────────────────────────────────────── - -@router.get("/subsidies", summary="范围内补贴申报列表") -async def gov_subsidies( - db: Database = Depends(get_db), - user: dict = Depends(require_permission("menu:gov_data")), -): - return {"items": await db.subsidies.list(user.get("scope_region_ids", []))} - - -@router.post("/subsidies/{aid}/approve", summary="补贴审批(按级别推进)") -async def gov_approve_subsidy( - aid: str, - request: Request, - db: Database = Depends(get_db), - user: dict = Depends(require_permission("menu:gov_data")), -): - from ...services.subsidy_service import SubsidyService - - updated = await SubsidyService(db).approve(aid, user) - await write_audit(db, action="subsidy.approve", resource="subsidy", resource_id=aid, - detail=updated.get("status"), user=user, request=request) - return updated - - -@router.post("/subsidies/{aid}/pay", summary="补贴发放") -async def gov_pay_subsidy( - aid: str, - request: Request, - db: Database = Depends(get_db), - user: dict = Depends(require_permission("menu:gov_data")), -): - from ...services.subsidy_service import SubsidyService - - updated = await SubsidyService(db).pay(aid) - await write_audit(db, action="subsidy.pay", resource="subsidy", resource_id=aid, - user=user, request=request) - return updated diff --git a/app/api/routers/rbac_investor.py b/app/api/routers/rbac_investor.py deleted file mode 100644 index 76e015d..0000000 --- a/app/api/routers/rbac_investor.py +++ /dev/null @@ -1,189 +0,0 @@ -# -*- coding: utf-8 -*- -"""投资人端端点:投资方向/项目/意向/路演(按权限与范围审核)/培训/组合。 - -路演发布审核规则: -- 运营方发布 → 免审(自身最高权限)。 -- 政务在自身权限范围(scope ⊆ publisher.scope_region_ids)内发布 → 免审;超出 → 需运营方审核。 -- 投资机构/载体/企业发布 → 一律需运营方审核。 -""" -from __future__ import annotations - -from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel - -from ..dependencies import get_db -from ..schemas.investor import PreferenceRequest, IntentRequest, RoadshowCreate -from ...rbac import require_roles, write_audit -from ...infrastructure.repositories import Database - -router = APIRouter(prefix="/investor", tags=["investor"]) - -# 可发布投融资活动的端口角色(审核规则见 _need_review) -_ROADSHOW_ROLES = ("service", "operator", "carrier") - - -def _need_review(user: dict, req: RoadshowCreate, db: Database) -> bool: - """按发布主体与范围判断是否需要审核。""" - role = user.get("role") - if role == "operator": - return False - if role == "service": - scope = user.get("scope_region_ids", []) - if req.scope_type == "region" and req.region_id and req.region_id in scope: - return False # 自身权限范围内,免审 - return True # 超出范围,需审核 - # 投资机构/载体/企业:一律需审核 - return True - - -def _pub_status(need_review: bool) -> str: - return "submitted" if need_review else "published" - - -@router.get("/dashboard", summary="投资人工作台") -async def investor_dashboard( - db: Database = Depends(get_db), - user: dict = Depends(require_roles("service")), -): - pref = await db.investor_prefs.get(user["id"]) or {} - portfolio = await db.portal_pages.get("investor", "portfolio") or {} - roadshows = [r for r in await db.roadshows.list(status="published")][:3] - return { - "preference": pref, - "stats": portfolio.get("stats", []), - "intents": len(await db.intents.list_for(user["id"])), - "recentRoadshows": roadshows, - } - - -@router.get("/preferences", summary="投资方向") -async def get_preferences( - db: Database = Depends(get_db), - user: dict = Depends(require_roles("service")), -): - return await db.investor_prefs.get(user["id"]) or {} - - -@router.put("/preferences", summary="保存投资方向") -async def set_preferences( - req: PreferenceRequest, - request: Request, - db: Database = Depends(get_db), - actor: dict = Depends(require_roles("service")), -): - pref = await db.investor_prefs.upsert(actor["id"], req.model_dump(exclude_none=True)) - await write_audit(db, action="preference.update", resource="investor", resource_id=actor["id"], - user=actor, request=request) - return pref - - -@router.get("/projects", summary="项目库") -async def list_projects( - db: Database = Depends(get_db), - _u: dict = Depends(require_roles("service")), -): - return await db.portal_pages.get("investor", "projects") or {"items": []} - - -@router.get("/trainings", summary="投融资培训") -async def list_trainings( - db: Database = Depends(get_db), - _u: dict = Depends(require_roles("service")), -): - return await db.portal_pages.get("investor", "trainings") or {"items": []} - - -@router.post("/trainings/{training_id}/enroll", summary="培训报名") -async def enroll_training( - training_id: str, - request: Request, - db: Database = Depends(get_db), - actor: dict = Depends(require_roles("service")), -): - data = await db.portal_pages.get("investor", "trainings") or {"items": []} - item = next((it for it in data.get("items", []) if it.get("title") == training_id), None) - name = (item or {}).get("title", training_id) - enroll = await db.training_enrolls.create(actor["id"], training_id, name) - await write_audit(db, action="training.enroll", resource="training", resource_id=enroll["id"], - detail=name, user=actor, request=request) - return enroll - - -@router.get("/my-trainings", summary="我的培训") -async def my_trainings( - db: Database = Depends(get_db), - user: dict = Depends(require_roles("service")), -): - return {"items": await db.training_enrolls.list_for(user["id"])} - - -@router.get("/portfolio", summary="投资组合") -async def portfolio( - db: Database = Depends(get_db), - _u: dict = Depends(require_roles("service")), -): - return await db.portal_pages.get("investor", "portfolio") or {"items": []} - - -@router.post("/projects/{project_id}/intent", summary="发起投资意向/约谈") -async def create_intent( - project_id: str, - req: IntentRequest, - request: Request, - db: Database = Depends(get_db), - actor: dict = Depends(require_roles("service")), -): - intent = await db.intents.create( - actor["id"], req.project_id or project_id, req.project_name, req.message, - ) - await write_audit(db, action="intent.create", resource="investor", resource_id=intent["id"], - detail=req.project_name, user=actor, request=request) - return intent - - -@router.get("/intents", summary="我的投资意向") -async def list_intents( - db: Database = Depends(get_db), - user: dict = Depends(require_roles("service")), -): - return {"items": await db.intents.list_for(user["id"])} - - -@router.get("/roadshows", summary="路演活动列表") -async def list_roadshows( - status: str | None = None, - db: Database = Depends(get_db), - _u: dict = Depends(require_roles(*_ROADSHOW_ROLES)), -): - return {"items": await db.roadshows.list(status=status)} - - -@router.post("/roadshows", summary="发布路演活动(按权限与范围审核)") -async def create_roadshow( - req: RoadshowCreate, - request: Request, - db: Database = Depends(get_db), - actor: dict = Depends(require_roles(*_ROADSHOW_ROLES)), -): - from ...services.roadshow_service import RoadshowService - - rs = await RoadshowService(db).create(req, actor) - await write_audit(db, action="roadshow.publish", resource="roadshow", resource_id=rs["id"], - detail=f"{rs['title']} need_review={rs.get('need_review')}", user=actor, request=request) - return rs - - -@router.post("/roadshows/{roadshow_id}/register", summary="活动报名") -async def register_roadshow( - roadshow_id: str, - request: Request, - db: Database = Depends(get_db), - actor: dict = Depends(require_roles(*_ROADSHOW_ROLES)), -): - rs = await db.roadshows.get(roadshow_id) - if rs is None or rs["status"] not in ("published", "registering"): - raise HTTPException(status_code=400, detail="活动不可报名") - if rs.get("register_deadline") and rs["register_deadline"] < "2099-01-01": - pass # 演示:不做硬性截止校验 - reg = await db.roadshow_regs.create(roadshow_id, actor["id"], role="investor") - return reg diff --git a/app/api/schemas/auth.py b/app/api/schemas/auth.py index b411ab9..886443b 100644 --- a/app/api/schemas/auth.py +++ b/app/api/schemas/auth.py @@ -108,11 +108,6 @@ class ProfileResponse(UserProfile): token: str = "" identities: list[IdentityInfo] = Field(default_factory=list) -class SelectIdentityRequest(BaseModel): - """切换到指定端口身份:为新身份签发令牌。""" - - identity_id: str - # --------------------------------------------------------------------------- # 统一登录(手机验证码 / 微信)—— 六端登录归一 # --------------------------------------------------------------------------- diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 2309093..227f4d3 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -629,33 +629,6 @@ class OpcTask(Base): updated_at: Mapped[str] = mapped_column(String, default="") -class UserIdentity(Base): - """账号↔端口绑定身份:一个账号可绑定多个端口/身份。 - - 身份即权限来源:``port`` 为业务端口(operator/government/provider/ - enterprise/carrier/opc),``role``=端口角色码(默认等于 port,供 - require_roles 匹配),``sub_role`` 为端口内细分角色(如 op_super_admin、 - gov_province、企业/园区/服务商的 admin/operator、OPC 的 certified/ - independent 等)。一个账号对应多行,登录后按所选身份签发令牌。 - """ - - __tablename__ = "user_identities" - - id: Mapped[str] = mapped_column(String, primary_key=True) - user_id: Mapped[str] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), nullable=False, - ) - port: Mapped[str] = mapped_column(String, default="opc") # operator/government/provider/enterprise/carrier/opc - role: Mapped[str] = mapped_column(String, default="opc_member") # RBAC 角色码 - sub_role: Mapped[str | None] = mapped_column(String, nullable=True) # 端口内细分角色 - name: Mapped[str] = mapped_column(String, default="") # 身份展示名 - org_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True) - region_id: Mapped[str | None] = mapped_column(ForeignKey("regions.id"), nullable=True) - status: Mapped[str] = mapped_column(String, default="active") # active | disabled - created_at: Mapped[str] = mapped_column(String, default="") - updated_at: Mapped[str] = mapped_column(String, default="") - - # =========================================================================== # 培训业务子应用(原 app/training 独立 opc.db → 并入唯一总库) # =========================================================================== diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index d7b7026..3225af7 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -54,7 +54,6 @@ from .models import ( TaskClaim, TrainingEnrollment, User, - UserIdentity, ) from .security import hash_password, verify_password @@ -117,14 +116,6 @@ def new_id(prefix: str) -> str: return f"{prefix}_{secrets.token_hex(12)}" -# 业务角色 → 端口映射(与 rbac_admin._port_for_role 保持一致) -_PORT_FOR_ROLE = { - "opc_member": "opc", "carrier": "carrier", "enterprise": "enterprise", - "provider": "provider", "government": "government", "operator": "operator", - "investor": "investor", "developer": "developer", -} - - def is_expired(expires_at: str) -> bool: """判断 ``expires_at``(ISO 字符串)是否已过期。""" try: @@ -463,13 +454,6 @@ class UserRepository: if not getattr(k, f) and av: setattr(k, f, av) - # 2) 用户身份改挂 keeper - idents = (await self.session.scalars( - select(UserIdentity).where(UserIdentity.user_id == absorbed_id) - )).all() - for it in idents: - it.user_id = keeper_id - # 3) 审计改指 keeper(FK 引用,避免删除被阻断) await self.session.execute( update(AuditLog).where(AuditLog.user_id == absorbed_id).values(user_id=keeper_id) @@ -538,18 +522,6 @@ class UserRepository: u = await self.session.get(User, user_id) if u is None: return None - # 同步该用户"主角色"身份(角色改变需对已绑定身份会话生效) - new_port = _PORT_FOR_ROLE.get(role, "opc") - for ident in await self.session.scalars( - select(UserIdentity).where(UserIdentity.user_id == user_id) - ): - if ident.role == u.role: - ident.role = role - ident.sub_role = sub_role - ident.port = new_port - ident.org_id = org_id - ident.region_id = region_id - ident.updated_at = utcnow_iso() u.role = role u.sub_role = sub_role u.org_id = org_id @@ -1528,93 +1500,6 @@ class OpcTaskRepository: } -# --------------------------------------------------------------------------- -# 账号↔端口身份(多身份绑定) -# --------------------------------------------------------------------------- -class IdentityRepository: - """一个账号可绑定的多个端口身份(登录后选择其一进入)。""" - - def __init__(self, session: AsyncSession): - self.session = session - - async def _to_dict(self, i: UserIdentity) -> dict: - org = await self.session.get(Organization, i.org_id) if i.org_id else None - region = await self.session.get(Region, i.region_id) if i.region_id else None - return { - "id": i.id, - "user_id": i.user_id, - "port": i.port, - "role": i.role, - "sub_role": i.sub_role, - "name": i.name, - "org_id": i.org_id, - "region_id": i.region_id, - "org_name": org.name if org else None, - "region_name": region.name if region else None, - "status": i.status, - "created_at": i.created_at, - "updated_at": i.updated_at, - } - - async def get(self, identity_id: str) -> dict | None: - row = await self.session.get(UserIdentity, identity_id) - return await self._to_dict(row) if row else None - - async def get_for_user(self, identity_id: str, user_id: str) -> dict | None: - row = await self.session.scalar( - select(UserIdentity).where( - UserIdentity.id == identity_id, - UserIdentity.user_id == user_id, - ) - ) - return await self._to_dict(row) if row else None - - async def list_for_user(self, user_id: str, active_only: bool = True) -> list[dict]: - stmt = select(UserIdentity).where(UserIdentity.user_id == user_id) - if active_only: - stmt = stmt.where(UserIdentity.status == "active") - rows = (await self.session.scalars(stmt.order_by(UserIdentity.port))).all() - return [await self._to_dict(i) for i in rows] - - async def create( - self, - user_id: str, - *, - port: str, - role: str | None = None, - sub_role: str | None = None, - name: str = "", - org_id: str | None = None, - region_id: str | None = None, - ) -> dict: - now = utcnow_iso() - row = UserIdentity( - id=new_id("ident"), - user_id=user_id, - port=port, - role=role or port, - sub_role=sub_role, - name=name, - org_id=org_id, - region_id=region_id, - status="active", - created_at=now, - updated_at=now, - ) - self.session.add(row) - await self.session.commit() - return await self._to_dict(row) - - async def set_status(self, identity_id: str, status: str) -> dict | None: - row = await self.session.get(UserIdentity, identity_id) - if row is None: - return None - row.status = status - row.updated_at = utcnow_iso() - await self.session.commit() - return await self._to_dict(row) - - # --------------------------------------------------------------------------- # 端口工作台(按端口 JSON 载荷) # --------------------------------------------------------------------------- @@ -2223,7 +2108,6 @@ class Database: self.providers = ProviderRepository(self.session) self.content = ContentRepository(self.session) self.config = ConfigRepository(self.session) - self.identities = IdentityRepository(self.session) self.portal_dashboards = PortalDashboardRepository(self.session) self.portal_pages = PortalPageRepository(self.session) self.opc_profiles = OpcProfileRepository(self.session) diff --git a/app/infrastructure/seed.py b/app/infrastructure/seed.py index d41abde..a0c75cd 100644 --- a/app/infrastructure/seed.py +++ b/app/infrastructure/seed.py @@ -40,7 +40,6 @@ from .models import ( TaskCategory, TrainingEnrollment, User, - UserIdentity, Agent, ) from .repositories import AGENT_SEED, new_id, utcnow_iso @@ -323,13 +322,11 @@ async def seed_data(session: AsyncSession) -> None: await _ensure_extra_demo_users(session, now) await _seed_opc_business(session, now) await _seed_task_categories(session, now) - await _migrate_identities(session, now) await _seed_port_dashboards(session, now) await _seed_market(session, now) await _seed_port_pages(session, now) await _seed_investor(session, now) await _seed_ecosystem(session, now) - await _ensure_port_agents(session, now) await _seed_org_members(session, now) await session.commit() return @@ -384,13 +381,11 @@ async def seed_data(session: AsyncSession) -> None: await _seed_task_categories(session, now) await _seed_operator_business(session, now) await _seed_opc_business(session, now) - await _migrate_identities(session, now) await _seed_port_dashboards(session, now) await _seed_market(session, now) await _seed_port_pages(session, now) await _seed_investor(session, now) await _seed_ecosystem(session, now) - await _ensure_port_agents(session, now) await _seed_org_members(session, now) await session.commit() @@ -553,114 +548,6 @@ async def _seed_opc_business(session: AsyncSession, now: str) -> None: ]) -# --------------------------------------------------------------------------- -# 账号↔端口身份(回填 + 多身份演示) -# --------------------------------------------------------------------------- -_ROLE_TO_PORT = { - "opc_member": "opc", - "carrier": "carrier", - "enterprise": "enterprise", - "provider": "provider", - "government": "government", - "operator": "operator", -} - -_SUB_LABELS = { - "op_super_admin": "超级管理员", "op_admin": "管理员", "op_customer_service": "客服", - "op_finance": "财务", "op_analyst": "数据分析", "op_techops": "技术运维", - "gov_province": "省级政务", "gov_city": "市级政务", "gov_district": "区县级政务", - "gov_org_admin": "组织机构管理员", "gov_op_account": "操作账号", - "admin": "管理员", "operator": "操作者", - "certified": "认证OPC", "independent": "独立OPC", -} - -_PORT_LABELS = { - "operator": "运营端", "government": "政务端", "provider": "服务端", - "enterprise": "企业端", "carrier": "园区端", "opc": "OPC端", -} - - -def _identity_label(role: str, sub_role: str | None) -> str: - port = _PORT_LABELS.get(_ROLE_TO_PORT.get(role, role), role) - sub = _SUB_LABELS.get(sub_role or "", "") - return f"{port}-{sub}" if sub else port - - -async def _migrate_identities(session: AsyncSession, now: str) -> None: - """为既有账号回填端口身份;并为演示账号补充多身份绑定(幂等)。""" - # session 关闭 autoflush:先 flush 让本会话新增的用户可见 - await session.flush() - - # 1. 每个已有业务角色的用户 -> 一条身份(缺失时补建) - for u in await session.scalars(select(User)): - if not u.role: - continue - has = await session.scalar( - select(UserIdentity.id).where(UserIdentity.user_id == u.id).limit(1) - ) - if has is not None: - continue - session.add( - UserIdentity( - id=new_id("ident"), - user_id=u.id, - port=_ROLE_TO_PORT.get(u.role, u.role), - role=u.role, - sub_role=u.sub_role, - name=_identity_label(u.role, u.sub_role), - org_id=u.org_id, - region_id=u.region_id, - status="active", - created_at=now, updated_at=now, - ) - ) - - # 2. 演示多身份绑定:ent01 同时具备「企业-管理员」与「OPC-认证」两个身份, - # 用于验证登录后多身份选择流程。 - ent_extra = await session.scalar( - select(UserIdentity.id).where(UserIdentity.id == "ident_ent01_opc").limit(1) - ) - if ent_extra is None and await session.get(User, "u_ent_01") is not None: - session.add( - UserIdentity( - id="ident_ent01_opc", - user_id="u_ent_01", - port="opc", - role="opc_member", - sub_role="certified", - name="OPC端-认证OPC", - org_id=None, - region_id="r_dist_pl", - status="active", - created_at=now, updated_at=now, - ) - ) - - # 3. 超级账号 pine:绑定全部 6 端口身份(演示一账号多端)。 - _PINE_ALL_PORTS = [ - ("ident_pine_gov", "government", "government", "gov_province", "政务端-省级"), - ("ident_pine_pro", "provider", "provider", "admin", "服务端-管理员"), - ("ident_pine_ent", "enterprise", "enterprise", "admin", "企业端-管理员"), - ("ident_pine_car", "carrier", "carrier", "admin", "园区端-管理员"), - ("ident_pine_opc", "opc", "opc_member", "certified", "OPC端-认证OPC"), - ("ident_pine_inv", "investor", "investor", "org_director", "投资人端-机构总监"), - ("ident_pine_dev", "developer", "developer", "dev_org_admin", "开放平台-机构开发者"), - ] - for ident_id, port, role, sub_role, name in _PINE_ALL_PORTS: - if await session.scalar(select(UserIdentity.id).where(UserIdentity.id == ident_id).limit(1)) is not None: - continue - if await session.get(User, "u_demo_01") is None: - break - session.add( - UserIdentity( - id=ident_id, user_id="u_demo_01", port=port, role=role, - sub_role=sub_role, name=name, - org_id=None, region_id="r_prov_yn", - status="active", created_at=now, updated_at=now, - ) - ) - - # --------------------------------------------------------------------------- # 端口工作台聚合数据(企业/载体/服务商/政务) # --------------------------------------------------------------------------- @@ -1124,22 +1011,6 @@ async def _seed_ecosystem(session: AsyncSession, now: str) -> None: ]) -async def _ensure_port_agents(session: AsyncSession, now: str) -> None: - """为每个账号的每个端口身份确保默认/QA 智能体(多端口彻底隔离)。""" - await session.flush() # 确保此前新增的身份可见 - rows = (await session.execute(select(UserIdentity.user_id, UserIdentity.port))).all() - seen: set[tuple[str, str]] = set() - for user_id, port in rows: - if (user_id, port) in seen: - continue - seen.add((user_id, port)) - existing_ids = set( - (await session.execute(select(Agent.id).where(Agent.user_id == user_id, Agent.port == port))).scalars() - ) - for seed in AGENT_SEED: - await _add_agent(session, seed, user_id, port, now) - - async def _seed_org_members(session: AsyncSession, now: str) -> None: """机构成员(机构主账号 + 子账号),幂等(以 organization_members 为标记)。""" await session.flush() diff --git a/app/main.py b/app/main.py index 61a8dd7..903cd29 100644 --- a/app/main.py +++ b/app/main.py @@ -18,13 +18,10 @@ from app.api.routers import agents as agents_router from app.api.routers import auth as auth_router from app.api.routers import bootstrap as bootstrap_router from app.api.routers import rbac_admin as rbac_admin_router -from app.api.routers import rbac_government as rbac_government_router from app.api.routers import rbac_opc as rbac_opc_router -from app.api.routers import rbac_investor as rbac_investor_router from app.api.routers import rbac_operator as rbac_operator_router from app.api.routers import rbac_org as rbac_org_router from app.api.routers import rbac_ecosystem as rbac_ecosystem_router -from app.api.routers import rbac_developer as rbac_developer_router from app.api.routers import rbac_portals as rbac_portals_router from app.api.routers import templates as templates_router from app.api.routers import relay as relay_router @@ -79,14 +76,11 @@ app.include_router(agents_router.router) app.include_router(templates_router.router) app.include_router(bootstrap_router.router) app.include_router(rbac_admin_router.router) -app.include_router(rbac_government_router.router) app.include_router(rbac_opc_router.router) -app.include_router(rbac_investor_router.router) app.include_router(rbac_operator_router.router) app.include_router(rbac_org_router.router) app.include_router(rbac_portals_router.router) app.include_router(rbac_ecosystem_router.router) -app.include_router(rbac_developer_router.router) app.include_router(relay_router.router) diff --git a/app/services/user_admin_service.py b/app/services/user_admin_service.py index 2cd7d70..828c34c 100644 --- a/app/services/user_admin_service.py +++ b/app/services/user_admin_service.py @@ -6,14 +6,6 @@ from fastapi import HTTPException from ..infrastructure.repositories import Database -# 业务角色 → 端口映射(与领域规则一致) -_PORT_FOR_ROLE = { - "opc_member": "opc", "carrier": "carrier", "enterprise": "enterprise", - "provider": "provider", "government": "government", "operator": "operator", - "investor": "investor", "developer": "developer", -} - - class UserAdminService: """运营端账号/角色管理。""" @@ -43,11 +35,6 @@ class UserAdminService: role=req.role, sub_role=req.sub_role, org_id=req.org_id, region_id=req.region_id, source="admin", auth_type="admin", ) - await self.db.identities.create( - user["id"], port=_PORT_FOR_ROLE.get(req.role, "opc"), role=req.role, - sub_role=req.sub_role, org_id=req.org_id, region_id=req.region_id, - name=req.nickname or req.username, - ) return user async def set_user_role(self, actor: dict, user_id: str, role: str, sub_role: str, org_id: str, region_id: str) -> dict: diff --git a/tests/test_auth_identity.py b/tests/test_auth_identity.py index d9c6b75..362ee99 100644 --- a/tests/test_auth_identity.py +++ b/tests/test_auth_identity.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""账号↔端口多身份:登录返回身份列表、选择身份签发新令牌、按身份访问。""" +"""账号单一角色(去多身份):登录返回空 identities、role=账号类型、按角色访问。""" from __future__ import annotations from fastapi.testclient import TestClient @@ -15,59 +15,31 @@ def auth(token: str) -> dict: return {"Authorization": f"Bearer {token}"} -def test_single_identity_auto_login(client): - """单身份账号(opc01)登录即绑定该身份,可直接访问对应端口。""" +def test_single_role_login(client): + """opc01(单角色 OPC)登录:identities 恒空、role=opc_member、可按角色访问 OPC 端口。""" body = login(client, "opc01") - assert len(body["identities"]) == 1 - assert body["identities"][0]["port"] == "opc" + assert body["identities"] == [] assert body["role"] == "opc_member" - assert body.get("identity_id") == body["identities"][0]["id"] + assert bool(body["token"]) res = client.get("/opc/dashboard", headers=auth(body["token"])) assert res.status_code == 200, res.text -def test_multi_identity_login_returns_all_and_neutral(client): - """多身份账号(ent01:企业 + OPC)登录返回全部身份,令牌为中性(回退单角色)。""" - body = login(client, "ent01") - ports = [i["port"] for i in body["identities"]] - assert "enterprise" in ports and "opc" in ports - - # 中性令牌按 users.role 回退为 enterprise:访问 OPC 端口应 403 - res = client.get("/opc/dashboard", headers=auth(body["token"])) - assert res.status_code == 403 +def test_operator_login_has_operator_role(client): + """pine(平台运营方超管)登录:role=operator,具备运营端权限。""" + body = login(client, "pine") + assert body["role"] == "operator" + assert "permissions" in body and len(body["permissions"]) > 0 + assert any(p.startswith("menu:") for p in body["permissions"]) -def test_select_identity_grants_opc_access(client): - """选择 OPC 身份后,新令牌可访问 OPC 端口,且 /me 反映该身份。""" - body = login(client, "ent01") - opc_identity = next(i for i in body["identities"] if i["port"] == "opc") - - res = client.post( - "/auth/select-identity", - headers=auth(body["token"]), - json={"identity_id": opc_identity["id"]}, - ) - assert res.status_code == 200, res.text - selected = res.json() - assert selected["role"] == "opc_member" - assert selected["identity_id"] == opc_identity["id"] - - dash = client.get("/opc/dashboard", headers=auth(selected["token"])) - assert dash.status_code == 200, dash.text - - me = client.get("/auth/me", headers=auth(selected["token"])).json() - assert me["role"] == "opc_member" - assert me.get("port") == "opc" - - -def test_select_identity_rejects_other_users_identity(client): - """不能选择不属于当前账号的身份。""" +def test_select_identity_endpoint_removed(client): + """多身份选择端点已移除:/auth/select-identity 不再存在(405)。""" body = login(client, "opc01") - other = "ident_ent01_opc" # 属于 ent01 的身份 id res = client.post( "/auth/select-identity", headers=auth(body["token"]), - json={"identity_id": other}, + json={"identity_id": "whatever"}, ) - assert res.status_code == 404 + assert res.status_code == 405 or res.status_code == 404 diff --git a/tests/test_portals.py b/tests/test_portals.py index 926b597..8867fcb 100644 --- a/tests/test_portals.py +++ b/tests/test_portals.py @@ -152,51 +152,21 @@ def test_ecosystem_crosscut(client): assert join.status_code == 200 and join.json()["joined"] is True -def test_agent_per_port_isolation(client): - # 同一账号在不同端口拥有各自独立的智能体(多端口彻底隔离) - body = client.post("/auth/login", json={"username": "pine", "password": "123456"}).json() - token = body["token"] - opc_ident = next(i for i in body["identities"] if i["port"] == "opc") - op_id = next(i for i in body["identities"] if i["port"] == "operator") - # 运营端:创建专属智能体 - sel_op = client.post("/auth/select-identity", headers=auth(token), json={"identity_id": op_id["id"]}).json() - created = client.post("/agents", headers=auth(sel_op["token"]), json={"name": "运营专属助手"}).json() - assert created["port"] == "operator" - op_agents = client.get("/agents", headers=auth(sel_op["token"])).json() - assert any(a["id"] == created["id"] for a in op_agents) - # OPC 端:看不到运营端的专属智能体(彻底隔离) - sel_opc = client.post("/auth/select-identity", headers=auth(token), json={"identity_id": opc_ident["id"]}).json() - opc_agents = client.get("/agents", headers=auth(sel_opc["token"])).json() - assert all(a["id"] != created["id"] for a in opc_agents) - assert all(a["port"] == "opc" for a in opc_agents) - - -def test_agent_neutral_token_fully_isolated(client): - # 多身份账号登录后未 select-identity 的中性令牌:不得跨端口访问任何智能体 - body = client.post("/auth/login", json={"username": "pine", "password": "123456"}).json() - neutral = body["token"] - # 中性令牌(未解析端口身份)访问智能体一律 403 - assert client.get("/agents", headers=auth(neutral)).status_code == 403 - # 先选身份创建 opc 智能体 - opc_ident = next(i for i in body["identities"] if i["port"] == "opc")["id"] - ot = client.post( - "/auth/select-identity", headers=auth(neutral), json={"identity_id": opc_ident} - ).json()["token"] - created = client.post("/agents", headers=auth(ot), json={"name": "OPC专属"}).json() - assert created["port"] == "opc" - # 中性令牌改/删该智能体:403(不能跨端口或绕过身份) - assert ( - client.put( - f"/agents/{created['id']}", headers=auth(neutral), json={"name": "x"} - ).status_code - == 403 - ) - assert client.delete(f"/agents/{created['id']}", headers=auth(neutral)).status_code == 403 - # 带身份令牌仍可正常管理(回归) - upd = client.put( - f"/agents/{created['id']}", headers=auth(ot), json={"name": "OPC专属v2"} - ) - assert upd.status_code == 200 and upd.json()["name"] == "OPC专属v2" +def test_agents_single_role(client): + # 单角色:智能体归账号所有(不再按端口隔离),可增改删;他人不可见/不可改 + pine = login(client, "pine") + created = client.post("/agents", headers=auth(pine), json={"name": "我的助手"}).json() + assert created["name"] == "我的助手" + mine = client.get("/agents", headers=auth(pine)).json() + assert any(a["id"] == created["id"] for a in mine) + # 他人(OPC)不可见 + opc = login(client, "opc01") + other = client.get("/agents", headers=auth(opc)).json() + assert all(a["id"] != created["id"] for a in other) + # 本人可更新/删除 + upd = client.put(f"/agents/{created['id']}", headers=auth(pine), json={"name": "助手v2"}) + assert upd.status_code == 200 and upd.json()["name"] == "助手v2" + assert client.delete(f"/agents/{created['id']}", headers=auth(pine)).status_code == 200 def test_org_members(client):