feat: 支持按权益名称导入用户

This commit is contained in:
2026-08-19 15:11:49 +08:00
parent efe835be81
commit 6ffdd2c259
5 changed files with 213 additions and 13 deletions

View File

@@ -35,7 +35,7 @@ from app.api.pagination import page_result
router = APIRouter()
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期"]
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期", "权益名称"]
class AdminGenerateReportRequest(BaseModel):
@@ -155,17 +155,7 @@ def download_user_import_template(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> 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)})

View File

@@ -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):

View File

@@ -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 {