diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/UserManagementView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/UserManagementView.vue index 84bf352..a7b20ba 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/components/UserManagementView.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/components/UserManagementView.vue @@ -279,7 +279,7 @@ async function deleteUser(row: AdminUser) {

批量导入学员

-

下载模板后填写学员信息,再上传 .xlsx 文件导入。

+

下载模板后填写学员信息和权益名称,再上传 .xlsx 文件导入;权益名称须与系统完全一致,留空或未匹配时不分配专属权益。

StreamingResponse: - workbook = Workbook() - sheet = workbook.active - sheet.title = "学员导入模板" - sheet.append(STUDENT_TEMPLATE_HEADERS) - sheet.append(["13800000001", "张三", "三三", _default_daily_chat_limit(db), "启用", "2026-12-31 23:59:59"]) - sheet.append(["13800000002", "李四", "", "", "启用", ""]) - - widths = [18, 14, 14, 16, 12, 22] - for index, width in enumerate(widths, start=1): - sheet.column_dimensions[chr(64 + index)].width = width - + workbook = _user_import_template_workbook(db) stream = BytesIO() workbook.save(stream) stream.seek(0) @@ -176,6 +166,32 @@ def download_user_import_template( ) +def _user_import_template_workbook(db: Session) -> Workbook: + workbook = Workbook() + sheet = workbook.active + sheet.title = "学员导入模板" + sheet.append(STUDENT_TEMPLATE_HEADERS) + available_plan = EntitlementService.list_plans(db) + example_plan_name = available_plan[0].name if available_plan else "" + sheet.append( + [ + "13800000001", + "张三", + "三三", + _default_daily_chat_limit(db), + "启用", + "2026-12-31 23:59:59", + example_plan_name, + ] + ) + sheet.append(["13800000002", "李四", "", "", "启用", "", ""]) + + widths = [18, 14, 14, 16, 12, 22, 22] + for index, width in enumerate(widths, start=1): + sheet.column_dimensions[chr(64 + index)].width = width + return workbook + + @router.post("/user/import/excel") def import_users_excel( file: UploadFile = File(...), @@ -214,6 +230,7 @@ def import_users_excel( dailyChatLimit=_parse_optional_int(_excel_value(row, header_map, "每日聊天额度")), status=_parse_status(_excel_value(row, header_map, "状态")), expiredAt=_parse_optional_datetime(_excel_value(row, header_map, "有效期")), + entitlementName=_cell_text(_excel_value(row, header_map, "权益名称")) or None, ) students.append((row_number, item)) except (ValueError, ValidationError) as exc: @@ -243,6 +260,11 @@ def _import_user_items( failed: list[dict] = list(initial_failures or []) seen: set[str] = set() default_daily_limit = _default_daily_chat_limit(db) + entitlement_plans = { + plan.name.strip(): plan + for plan in EntitlementService.list_plans(db) + if plan.name.strip() + } for row_number, item in students: phone = _normalize_phone(item.phone) @@ -264,6 +286,28 @@ def _import_user_items( updated += 1 _apply_user_payload(user, item, default_daily_limit) db.add(user) + db.flush() + entitlement_name = (item.entitlementName or "").strip() + entitlement_plan = entitlement_plans.get(entitlement_name) + if entitlement_plan is not None: + EntitlementService.assign_user_plan( + db, + user=user, + plan_id=entitlement_plan.id, + operated_by=current_admin.id, + remark="用户批量导入按权益名称匹配", + ) + else: + EntitlementService.clear_user_plan( + db, + user=user, + operated_by=current_admin.id, + reason=( + f"导入的权益名称不存在:{entitlement_name}" + if entitlement_name + else "导入时未填写权益名称" + ), + ) except ValueError as exc: failed.append({"row": row_number, "phone": item.phone, "reason": str(exc)}) diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py index 6fc090d..be2a6ff 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py @@ -99,6 +99,7 @@ class AdminUserImportItem(BaseModel): status: int = Field(default=1, ge=0, le=1) dailyChatLimit: int | None = Field(default=None, ge=0, le=100000) expiredAt: datetime | None = None + entitlementName: str | None = Field(default=None, max_length=80) class AdminUserImportRequest(BaseModel): diff --git a/ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py b/ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py index 050cd8f..60f5b30 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/entitlement_service.py @@ -312,6 +312,41 @@ class EntitlementService: ) return entitlement + @staticmethod + def clear_user_plan( + db: Session, + *, + user: User, + operated_by: int | None, + reason: str | None = None, + ) -> None: + """Remove explicit entitlements so the user returns to the unassigned/default state.""" + current_rows = list( + db.scalars( + select(UserEntitlement) + .where(UserEntitlement.user_id == user.id, UserEntitlement.status == "active") + .order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc()) + ) + ) + if not current_rows: + return + for current in current_rows: + current.status = "replaced" + db.add(current) + latest = current_rows[0] + db.add( + UserEntitlementLog( + user_id=user.id, + entitlement_id=latest.id, + from_plan_id=latest.plan_id, + to_plan_id=None, + action="clear", + detail_json=json.dumps({"reason": reason}, ensure_ascii=False), + operated_by=operated_by, + created_at=_now(), + ) + ) + def plan_dict(plan: EntitlementPlan) -> dict: return { diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_admin_user_import.py b/ai_knowledge_base_v2/apps/backend/tests/test_admin_user_import.py new file mode 100644 index 0000000..cc9f5e9 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_admin_user_import.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.api.admin_users import ( + STUDENT_TEMPLATE_HEADERS, + _import_user_items, + _user_import_template_workbook, +) +from app.models import Base +from app.models.entitlement import EntitlementPlan, UserEntitlement +from app.models.user import User +from app.schemas.admin import AdminUserImportItem + + +def _database() -> Session: + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + return Session(engine) + + +def test_user_import_template_contains_entitlement_name_and_uses_available_plan_example(): + with _database() as db: + db.add_all( + [ + EntitlementPlan(id=1, name="深度陪伴版", plan_type="deep", status=1, sort_order=1), + EntitlementPlan(id=2, name="已停用版", plan_type="basic", status=0, sort_order=0), + ] + ) + db.commit() + + sheet = _user_import_template_workbook(db).active + + assert [cell.value for cell in sheet[1]] == STUDENT_TEMPLATE_HEADERS + assert sheet.cell(row=2, column=7).value == "深度陪伴版" + assert sheet.cell(row=3, column=7).value == "" + + +def test_user_import_matches_entitlement_by_name_and_clears_missing_or_unknown_names(): + with _database() as db: + available = EntitlementPlan(id=1, name="深度陪伴版", plan_type="deep", status=1) + old_plan = EntitlementPlan(id=2, name="旧权益", plan_type="basic", status=1) + disabled = EntitlementPlan(id=3, name="停用权益", plan_type="basic", status=0) + existing_user = User(id=10, phone="13800000010", name="旧姓名", daily_chat_limit=10) + db.add_all([available, old_plan, disabled, existing_user]) + db.flush() + db.add(UserEntitlement(user_id=existing_user.id, plan_id=old_plan.id, status="active")) + db.commit() + + result = _import_user_items( + [ + ( + 2, + AdminUserImportItem( + phone="13800000011", + name="匹配用户", + entitlementName="深度陪伴版", + ), + ), + ( + 3, + AdminUserImportItem( + phone="13800000010", + name="更新用户", + entitlementName="系统不存在的权益", + ), + ), + ( + 4, + AdminUserImportItem( + phone="13800000012", + name="留空用户", + entitlementName=None, + ), + ), + ( + 5, + AdminUserImportItem( + phone="13800000013", + name="停用权益用户", + entitlementName="停用权益", + ), + ), + ], + db, + SimpleNamespace(id=99), + ) + + assert result == {"created": 3, "updated": 1, "failed": 0, "failures": []} + matched_user = db.scalar(select(User).where(User.phone == "13800000011")) + matched_entitlement = db.scalar( + select(UserEntitlement).where( + UserEntitlement.user_id == matched_user.id, + UserEntitlement.status == "active", + ) + ) + assert matched_entitlement is not None + assert matched_entitlement.plan_id == available.id + + for phone in ("13800000010", "13800000012", "13800000013"): + user = db.scalar(select(User).where(User.phone == phone)) + assert db.scalar( + select(UserEntitlement).where( + UserEntitlement.user_id == user.id, + UserEntitlement.status == "active", + ) + ) is None + + old_entitlement = db.scalar( + select(UserEntitlement).where(UserEntitlement.user_id == existing_user.id) + ) + assert old_entitlement.status == "replaced"