|
|
@@ -31,6 +31,7 @@ reviewtemplatesdb = control_db.teacher_review_templates
|
|
|
reviewdraftsdb = control_db.teacher_review_drafts
|
|
|
reviewsdb = control_db.teacher_reviews
|
|
|
statsdb = control_db.teacher_review_stats
|
|
|
+reviewtopicsdb = control_db.teacher_review_topics
|
|
|
profilesdb = control_db.directory_profiles
|
|
|
locationsdb = control_db.directory_locations
|
|
|
membershipsdb = control_db.directory_memberships
|
|
|
@@ -59,38 +60,45 @@ ORDER_STATUSES = ORDER_ACTIVE_STATUSES | ORDER_TERMINAL_STATUSES
|
|
|
REVIEW_SOURCES = {"qr_verified", "student_initiated"}
|
|
|
REVIEW_STATUSES = {"pending", "approved", "rejected", "withdrawn", "voided"}
|
|
|
DEFAULT_REVIEW_TEMPLATE = {
|
|
|
- "rating_questions": [
|
|
|
+ "questions": [
|
|
|
{
|
|
|
- "question_id": "service_effect",
|
|
|
- "label": "服务效果",
|
|
|
- "description": "本次服务是否达到预期。",
|
|
|
+ "question_id": "overall_experience",
|
|
|
+ "type": "single_choice",
|
|
|
+ "label": "这次服务体验怎么样?",
|
|
|
+ "description": "选择最符合实际体验的一项。",
|
|
|
"required": True,
|
|
|
- "weight": 40,
|
|
|
+ "weight": 100,
|
|
|
+ "options": [
|
|
|
+ {"value": "excellent", "label": "非常满意", "score": 5},
|
|
|
+ {"value": "good", "label": "满意", "score": 4},
|
|
|
+ {"value": "average", "label": "一般", "score": 3},
|
|
|
+ {"value": "poor", "label": "不满意", "score": 2},
|
|
|
+ {"value": "bad", "label": "非常不满意", "score": 1},
|
|
|
+ ],
|
|
|
},
|
|
|
{
|
|
|
- "question_id": "professionalism",
|
|
|
- "label": "专业程度",
|
|
|
- "description": "技师的专业能力与服务规范。",
|
|
|
- "required": True,
|
|
|
- "weight": 30,
|
|
|
- },
|
|
|
- {
|
|
|
- "question_id": "communication",
|
|
|
- "label": "沟通体验",
|
|
|
- "description": "沟通是否清晰、友好。",
|
|
|
- "required": True,
|
|
|
- "weight": 30,
|
|
|
+ "question_id": "service_highlights",
|
|
|
+ "type": "multiple_choice",
|
|
|
+ "label": "哪些方面值得肯定?",
|
|
|
+ "description": "可多选,也可以跳过。",
|
|
|
+ "required": False,
|
|
|
+ "options": [
|
|
|
+ {"value": "professional", "label": "专业可靠"},
|
|
|
+ {"value": "communication", "label": "沟通顺畅"},
|
|
|
+ {"value": "careful", "label": "服务细致"},
|
|
|
+ {"value": "transparent", "label": "价格透明"},
|
|
|
+ {"value": "punctual", "label": "准时守约"},
|
|
|
+ ],
|
|
|
},
|
|
|
- ],
|
|
|
- "text_questions": [
|
|
|
{
|
|
|
"question_id": "comment",
|
|
|
- "label": "评价内容",
|
|
|
- "description": "分享真实体验,避免包含隐私信息。",
|
|
|
+ "type": "text",
|
|
|
+ "label": "补充评价",
|
|
|
+ "description": "可选,请勿填写联系方式或精确地址。",
|
|
|
"required": False,
|
|
|
"max_length": 500,
|
|
|
- }
|
|
|
- ],
|
|
|
+ },
|
|
|
+ ]
|
|
|
}
|
|
|
BUILTIN_PACKAGE_TEMPLATES = (
|
|
|
{
|
|
|
@@ -101,7 +109,7 @@ BUILTIN_PACKAGE_TEMPLATES = (
|
|
|
"package_id": "builtin_package_at_store",
|
|
|
"name": "标准到店服务",
|
|
|
"category": "通用服务",
|
|
|
- "description": "顾客到店完成一次标准服务,具体内容可在接单后沟通确认。",
|
|
|
+ "description": "顾客到指定区域接受一次标准服务,具体地点和服务细节通过 Telegram 私聊沟通。",
|
|
|
"tags": ["到店", "标准服务"],
|
|
|
"service_modes": ["at_store"],
|
|
|
"price_mode": "fixed",
|
|
|
@@ -114,7 +122,12 @@ BUILTIN_PACKAGE_TEMPLATES = (
|
|
|
"excluded_items": "额外耗材和临时加项",
|
|
|
"preparation": "请提前说明具体需求",
|
|
|
"addons": [],
|
|
|
- "travel_fee": {"mode": "included", "amount": "0", "per_km": "0"},
|
|
|
+ "travel_fee": {
|
|
|
+ "mode": "quoted",
|
|
|
+ "amount": "0",
|
|
|
+ "per_km": "0",
|
|
|
+ "description": "无额外交通费;其他临时费用请提前沟通。",
|
|
|
+ },
|
|
|
"service_radius_km": None,
|
|
|
"out_of_range_policy": "",
|
|
|
},
|
|
|
@@ -127,7 +140,7 @@ BUILTIN_PACKAGE_TEMPLATES = (
|
|
|
"package_id": "builtin_package_onsite",
|
|
|
"name": "标准上门服务",
|
|
|
"category": "通用服务",
|
|
|
- "description": "技师上门完成一次标准服务,最终费用以确认报价为准。",
|
|
|
+ "description": "技师提供一次标准上门服务,具体地点和服务细节通过 Telegram 私聊沟通。",
|
|
|
"tags": ["上门", "标准服务"],
|
|
|
"service_modes": ["onsite"],
|
|
|
"price_mode": "starting_at",
|
|
|
@@ -140,7 +153,12 @@ BUILTIN_PACKAGE_TEMPLATES = (
|
|
|
"excluded_items": "交通费、额外耗材和临时加项",
|
|
|
"preparation": "请提前发送位置并说明具体需求",
|
|
|
"addons": [],
|
|
|
- "travel_fee": {"mode": "quoted", "amount": "0", "per_km": "0"},
|
|
|
+ "travel_fee": {
|
|
|
+ "mode": "quoted",
|
|
|
+ "amount": "0",
|
|
|
+ "per_km": "0",
|
|
|
+ "description": "上门车费根据距离另行沟通。",
|
|
|
+ },
|
|
|
"service_radius_km": 20,
|
|
|
"out_of_range_policy": "超出服务半径时,请先沟通交通费和是否可以接单。",
|
|
|
},
|
|
|
@@ -166,7 +184,12 @@ BUILTIN_PACKAGE_TEMPLATES = (
|
|
|
"excluded_items": "交通费、额外耗材和超时服务",
|
|
|
"preparation": "请提前说明服务内容和预计时长",
|
|
|
"addons": [],
|
|
|
- "travel_fee": {"mode": "quoted", "amount": "0", "per_km": "0"},
|
|
|
+ "travel_fee": {
|
|
|
+ "mode": "quoted",
|
|
|
+ "amount": "0",
|
|
|
+ "per_km": "0",
|
|
|
+ "description": "上门车费、耗材费和超时费用另行沟通。",
|
|
|
+ },
|
|
|
"service_radius_km": 20,
|
|
|
"out_of_range_policy": "超出服务半径时,请先沟通交通费和是否可以接单。",
|
|
|
},
|
|
|
@@ -226,9 +249,14 @@ def _normalize_tags(values: Any) -> list[str]:
|
|
|
|
|
|
|
|
|
def normalize_package(value: dict[str, Any], *, package_id: str | None = None) -> dict[str, Any]:
|
|
|
- modes = list(dict.fromkeys(str(item) for item in value.get("service_modes", [])))
|
|
|
- if not modes or set(modes) - SERVICE_MODES:
|
|
|
- raise ServiceDataError("invalid_service_mode", "套餐必须选择到店或上门服务方式。")
|
|
|
+ modes = list(
|
|
|
+ dict.fromkeys(
|
|
|
+ str(item)
|
|
|
+ for item in (value.get("service_modes") or ["at_store", "onsite"])
|
|
|
+ )
|
|
|
+ )
|
|
|
+ if set(modes) - SERVICE_MODES:
|
|
|
+ raise ServiceDataError("invalid_service_mode", "服务方式无效。")
|
|
|
price_mode = str(value.get("price_mode") or "negotiable")
|
|
|
if price_mode not in PRICE_MODES:
|
|
|
raise ServiceDataError("invalid_price_mode", "不支持该价格模式。")
|
|
|
@@ -278,12 +306,9 @@ def normalize_package(value: dict[str, Any], *, package_id: str | None = None) -
|
|
|
radius = float(radius)
|
|
|
if not 0.5 <= radius <= 200:
|
|
|
raise ServiceDataError("invalid_service_radius", "上门半径必须在 0.5 到 200 公里之间。")
|
|
|
- if "onsite" in modes and radius is None:
|
|
|
- raise ServiceDataError("service_radius_required", "上门套餐必须填写服务半径。")
|
|
|
out_of_range_policy = _clean_text(
|
|
|
value.get("out_of_range_policy"),
|
|
|
max_length=200,
|
|
|
- required="onsite" in modes,
|
|
|
)
|
|
|
source_type = str(value.get("source_type") or "custom")
|
|
|
if source_type not in {"template", "custom"}:
|
|
|
@@ -331,20 +356,15 @@ def _normalize_profile(value: dict[str, Any]) -> dict[str, Any]:
|
|
|
public_area_text = _clean_text(
|
|
|
value.get("public_area_text"), max_length=80, required=True
|
|
|
)
|
|
|
- venue_name = _clean_text(
|
|
|
- venue.get("name"), max_length=80, required="at_store" in modes
|
|
|
- )
|
|
|
- venue_address = _clean_text(
|
|
|
- venue.get("address_hint"), max_length=120, required="at_store" in modes
|
|
|
- )
|
|
|
- onsite_description = _clean_text(
|
|
|
- onsite.get("description"), max_length=300, required="onsite" in modes
|
|
|
- )
|
|
|
+ venue_name = _clean_text(venue.get("name"), max_length=80)
|
|
|
+ venue_address = _clean_text(venue.get("address_hint"), max_length=120)
|
|
|
+ onsite_description = _clean_text(onsite.get("description"), max_length=300)
|
|
|
return {
|
|
|
"headline": _clean_text(value.get("headline"), max_length=80, required=True),
|
|
|
"bio": _clean_text(value.get("bio"), max_length=800, required=True),
|
|
|
"tags": _normalize_tags(value.get("tags", [])),
|
|
|
- "contact_hours": _clean_text(value.get("contact_hours"), max_length=120, required=True),
|
|
|
+ "contact_hours": _clean_text(value.get("contact_hours"), max_length=120)
|
|
|
+ or "请通过 Telegram 私聊沟通具体服务信息",
|
|
|
"service_modes": modes,
|
|
|
"accepting_requests": bool(value.get("accepting_requests", True)),
|
|
|
"public_area_text": public_area_text,
|
|
|
@@ -389,6 +409,8 @@ async def ensure_service_indexes() -> None:
|
|
|
await reviewsdb.create_index([("qr_id", ASCENDING)], unique=True, sparse=True)
|
|
|
await reviewsdb.create_index([("technician_id", ASCENDING), ("status", ASCENDING), ("created_at", DESCENDING)])
|
|
|
await statsdb.create_index([("technician_id", ASCENDING), ("category", ASCENDING)], unique=True)
|
|
|
+ await reviewtopicsdb.create_index([("technician_id", ASCENDING)], unique=True)
|
|
|
+ await reviewtopicsdb.create_index([("forum_chat_id", ASCENDING), ("message_thread_id", ASCENDING)])
|
|
|
_indexes_ready = True
|
|
|
|
|
|
|
|
|
@@ -459,8 +481,18 @@ async def require_customer_allowed(user_id: int) -> dict[str, Any]:
|
|
|
return customer
|
|
|
|
|
|
|
|
|
-async def get_service_settings() -> dict[str, int]:
|
|
|
+async def get_service_settings() -> dict[str, Any]:
|
|
|
stored = await settingsdb.find_one({"settings_id": "global"}) or {}
|
|
|
+ forum_chat_id = str(
|
|
|
+ stored.get("service_review_forum_chat_id")
|
|
|
+ or getattr(wbb, "SERVICE_REVIEW_FORUM_CHAT_ID", "")
|
|
|
+ or ""
|
|
|
+ ).strip()
|
|
|
+ forum_username = str(
|
|
|
+ stored.get("service_review_forum_username")
|
|
|
+ or getattr(wbb, "SERVICE_REVIEW_FORUM_USERNAME", "")
|
|
|
+ or ""
|
|
|
+ ).strip().lstrip("@")
|
|
|
return {
|
|
|
"customer_max_open_orders": max(
|
|
|
1,
|
|
|
@@ -495,10 +527,12 @@ async def get_service_settings() -> dict[str, int]:
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
+ "review_forum_chat_id": forum_chat_id,
|
|
|
+ "review_forum_username": forum_username,
|
|
|
}
|
|
|
|
|
|
|
|
|
-async def set_service_settings(values: dict[str, Any]) -> dict[str, int]:
|
|
|
+async def set_service_settings(values: dict[str, Any]) -> dict[str, Any]:
|
|
|
current = await get_service_settings()
|
|
|
try:
|
|
|
normalized = {
|
|
|
@@ -522,9 +556,20 @@ async def set_service_settings(values: dict[str, Any]) -> dict[str, int]:
|
|
|
1,
|
|
|
min(168, int(values.get("review_qr_expiry_hours", current["review_qr_expiry_hours"]))),
|
|
|
),
|
|
|
+ "review_forum_chat_id": _clean_text(
|
|
|
+ values.get("review_forum_chat_id", current["review_forum_chat_id"]),
|
|
|
+ max_length=40,
|
|
|
+ ),
|
|
|
+ "review_forum_username": _clean_text(
|
|
|
+ values.get(
|
|
|
+ "review_forum_username",
|
|
|
+ current["review_forum_username"],
|
|
|
+ ),
|
|
|
+ max_length=64,
|
|
|
+ ).lstrip("@"),
|
|
|
}
|
|
|
except (TypeError, ValueError) as exc:
|
|
|
- raise ServiceDataError("invalid_service_settings", "履约设置必须是有效整数。") from exc
|
|
|
+ raise ServiceDataError("invalid_service_settings", "服务设置格式无效。") from exc
|
|
|
await settingsdb.update_one(
|
|
|
{"settings_id": "global"},
|
|
|
{
|
|
|
@@ -532,6 +577,8 @@ async def set_service_settings(values: dict[str, Any]) -> dict[str, int]:
|
|
|
"service_customer_max_open_orders": normalized["customer_max_open_orders"],
|
|
|
"service_customer_daily_request_limit": normalized["customer_daily_request_limit"],
|
|
|
"service_review_qr_expiry_hours": normalized["review_qr_expiry_hours"],
|
|
|
+ "service_review_forum_chat_id": normalized["review_forum_chat_id"],
|
|
|
+ "service_review_forum_username": normalized["review_forum_username"],
|
|
|
"updated_at": utc_now(),
|
|
|
},
|
|
|
"$setOnInsert": {"created_at": utc_now()},
|
|
|
@@ -664,8 +711,6 @@ async def publish_technician_profile(user_id: int, values: dict[str, Any], *, ac
|
|
|
raise ServiceDataError("technician_required", "只有已认证技师可以发布服务资料。")
|
|
|
if not profile.get("username"):
|
|
|
raise ServiceDataError("username_required", "技师必须设置有效的 Telegram 用户名。")
|
|
|
- if not await locationsdb.find_one({"user_id": int(user_id)}):
|
|
|
- raise ServiceDataError("technician_location_required", "技师必须设置有效服务位置。")
|
|
|
if not await membershipsdb.find_one({"user_id": int(user_id), "active": True}):
|
|
|
raise ServiceDataError("technician_membership_required", "技师必须仍是受管群当前成员。")
|
|
|
normalized = _normalize_profile(values)
|
|
|
@@ -689,9 +734,6 @@ async def get_technician_self_service_context(user_id: int) -> dict[str, Any]:
|
|
|
profile = await profilesdb.find_one({"user_id": int(user_id)})
|
|
|
if not profile:
|
|
|
raise ServiceDataError("technician_not_found", "未找到技师申请资料。")
|
|
|
- location_configured = bool(
|
|
|
- await locationsdb.find_one({"user_id": int(user_id)})
|
|
|
- )
|
|
|
membership_active = bool(
|
|
|
await membershipsdb.find_one({"user_id": int(user_id), "active": True})
|
|
|
)
|
|
|
@@ -705,13 +747,6 @@ async def get_technician_self_service_context(user_id: int) -> dict[str, Any]:
|
|
|
"message": "请先在 Telegram 设置用户名。",
|
|
|
}
|
|
|
)
|
|
|
- if not location_configured:
|
|
|
- issues.append(
|
|
|
- {
|
|
|
- "code": "location_required",
|
|
|
- "message": "请先在 Bot 中设置服务基准位置。",
|
|
|
- }
|
|
|
- )
|
|
|
if not membership_active:
|
|
|
issues.append(
|
|
|
{
|
|
|
@@ -725,6 +760,7 @@ async def get_technician_self_service_context(user_id: int) -> dict[str, Any]:
|
|
|
"username": profile.get("username"),
|
|
|
"eligibility": {"ready": not issues, "issues": issues},
|
|
|
"service_profile": profile.get("service_profile") or None,
|
|
|
+ "review_topic": await get_technician_review_topic(int(user_id)),
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -836,15 +872,17 @@ async def _get_package(technician_id: int, package_id: str) -> tuple[dict[str, A
|
|
|
|
|
|
async def list_available_technicians(
|
|
|
*,
|
|
|
- longitude: float,
|
|
|
- latitude: float,
|
|
|
+ longitude: float | None = None,
|
|
|
+ latitude: float | None = None,
|
|
|
max_distance_meters: float | None = None,
|
|
|
query: str = "",
|
|
|
page: int = 1,
|
|
|
page_size: int = 10,
|
|
|
) -> tuple[list[dict[str, Any]], int]:
|
|
|
- lon, lat = float(longitude), float(latitude)
|
|
|
- if not -180 <= lon <= 180 or not -90 <= lat <= 90:
|
|
|
+ use_distance = longitude is not None and latitude is not None
|
|
|
+ lon = float(longitude) if longitude is not None else 0.0
|
|
|
+ lat = float(latitude) if latitude is not None else 0.0
|
|
|
+ if use_distance and (not -180 <= lon <= 180 or not -90 <= lat <= 90):
|
|
|
raise ServiceDataError("invalid_coordinates", "位置坐标无效。")
|
|
|
filters: dict[str, Any] = {
|
|
|
"application_status": "approved",
|
|
|
@@ -871,8 +909,33 @@ async def list_available_technicians(
|
|
|
if int(item["user_id"]) in active_members
|
|
|
}
|
|
|
values: list[dict[str, Any]] = []
|
|
|
- if profiles:
|
|
|
- async for location in locationsdb.find({"user_id": {"$in": list(profiles)}}):
|
|
|
+ locations = {}
|
|
|
+ if profiles and use_distance:
|
|
|
+ locations = {
|
|
|
+ int(location["user_id"]): location
|
|
|
+ async for location in locationsdb.find(
|
|
|
+ {"user_id": {"$in": list(profiles)}}
|
|
|
+ )
|
|
|
+ }
|
|
|
+ for technician_id, profile in profiles.items():
|
|
|
+ service_profile = profile.get("service_profile") or {}
|
|
|
+ value = {
|
|
|
+ "user_id": int(profile["user_id"]),
|
|
|
+ "username": profile.get("username"),
|
|
|
+ "display_name": profile.get("display_name"),
|
|
|
+ "headline": service_profile.get("headline"),
|
|
|
+ "bio": service_profile.get("bio"),
|
|
|
+ "tags": service_profile.get("tags", []),
|
|
|
+ "public_area_text": service_profile.get("public_area_text"),
|
|
|
+ "service_modes": service_profile.get("service_modes", []),
|
|
|
+ "packages": service_profile.get("packages", []),
|
|
|
+ "distance_meters": None,
|
|
|
+ "distance_band": "",
|
|
|
+ }
|
|
|
+ if use_distance:
|
|
|
+ location = locations.get(technician_id)
|
|
|
+ if not location:
|
|
|
+ continue
|
|
|
point = location.get("point", {}).get("coordinates") or [
|
|
|
location.get("longitude"),
|
|
|
location.get("latitude"),
|
|
|
@@ -882,24 +945,23 @@ async def list_available_technicians(
|
|
|
distance = _distance_meters(lon, lat, float(point[0]), float(point[1]))
|
|
|
if max_distance_meters is not None and distance > float(max_distance_meters):
|
|
|
continue
|
|
|
- profile = profiles[int(location["user_id"])]
|
|
|
- service_profile = profile.get("service_profile") or {}
|
|
|
- values.append(
|
|
|
+ value.update(
|
|
|
{
|
|
|
- "user_id": int(profile["user_id"]),
|
|
|
- "username": profile.get("username"),
|
|
|
- "display_name": profile.get("display_name"),
|
|
|
- "headline": service_profile.get("headline"),
|
|
|
- "bio": service_profile.get("bio"),
|
|
|
- "tags": service_profile.get("tags", []),
|
|
|
- "public_area_text": service_profile.get("public_area_text"),
|
|
|
- "service_modes": service_profile.get("service_modes", []),
|
|
|
- "packages": service_profile.get("packages", []),
|
|
|
"distance_meters": round(distance, 2),
|
|
|
"distance_band": distance_band(distance),
|
|
|
}
|
|
|
)
|
|
|
- values.sort(key=lambda item: (item["distance_meters"], item["user_id"]))
|
|
|
+ values.append(value)
|
|
|
+ if use_distance:
|
|
|
+ values.sort(key=lambda item: (item["distance_meters"], item["user_id"]))
|
|
|
+ else:
|
|
|
+ values.sort(
|
|
|
+ key=lambda item: (
|
|
|
+ str(item.get("public_area_text") or "").casefold(),
|
|
|
+ str(item.get("display_name") or "").casefold(),
|
|
|
+ item["user_id"],
|
|
|
+ )
|
|
|
+ )
|
|
|
total = len(values)
|
|
|
start = (max(1, page) - 1) * max(1, page_size)
|
|
|
return values[start : start + max(1, page_size)], total
|
|
|
@@ -1370,30 +1432,136 @@ async def claim_review_qr(token: str, customer_id: int) -> tuple[dict[str, Any],
|
|
|
return document, order
|
|
|
|
|
|
|
|
|
-def normalize_review_template(values: dict[str, Any]) -> dict[str, Any]:
|
|
|
- ratings = []
|
|
|
+def _legacy_review_questions(values: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
+ questions = []
|
|
|
for item in values.get("rating_questions", []):
|
|
|
- if len(ratings) >= 5:
|
|
|
- raise ServiceDataError("too_many_review_questions", "评分维度最多 5 个。")
|
|
|
- weight = int(item.get("weight") or 0)
|
|
|
- if weight <= 0:
|
|
|
- raise ServiceDataError("invalid_review_weight", "评分权重必须大于 0。")
|
|
|
- ratings.append({"question_id": str(item.get("question_id") or uuid4().hex[:12]), "label": _clean_text(item.get("label"), max_length=40, required=True), "description": _clean_text(item.get("description"), max_length=160), "required": bool(item.get("required", True)), "weight": weight})
|
|
|
- if not ratings or sum(item["weight"] for item in ratings) != 100:
|
|
|
- raise ServiceDataError("invalid_review_weight", "评分维度权重合计必须为 100%。")
|
|
|
- texts = []
|
|
|
+ questions.append(
|
|
|
+ {
|
|
|
+ **item,
|
|
|
+ "type": "single_choice",
|
|
|
+ "options": [
|
|
|
+ {"value": str(score), "label": f"{score} 分", "score": score}
|
|
|
+ for score in range(5, 0, -1)
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ )
|
|
|
for item in values.get("text_questions", []):
|
|
|
- if len(texts) >= 2:
|
|
|
- raise ServiceDataError("too_many_review_questions", "文字问题最多 2 个。")
|
|
|
- texts.append({"question_id": str(item.get("question_id") or uuid4().hex[:12]), "label": _clean_text(item.get("label"), max_length=40, required=True), "description": _clean_text(item.get("description"), max_length=160), "required": bool(item.get("required")), "max_length": max(50, min(1000, int(item.get("max_length") or 500)))})
|
|
|
- return {"rating_questions": ratings, "text_questions": texts}
|
|
|
+ questions.append({**item, "type": "text"})
|
|
|
+ return questions
|
|
|
+
|
|
|
+
|
|
|
+def normalize_review_template(values: dict[str, Any]) -> dict[str, Any]:
|
|
|
+ raw_questions = values.get("questions")
|
|
|
+ if raw_questions is None:
|
|
|
+ raw_questions = _legacy_review_questions(values)
|
|
|
+ if not isinstance(raw_questions, list) or not 1 <= len(raw_questions) <= 8:
|
|
|
+ raise ServiceDataError(
|
|
|
+ "invalid_review_questions",
|
|
|
+ "评价模板必须包含 1 到 8 个问题。",
|
|
|
+ )
|
|
|
+ questions = []
|
|
|
+ question_ids: set[str] = set()
|
|
|
+ scoring_weight = 0
|
|
|
+ for raw in raw_questions:
|
|
|
+ if not isinstance(raw, dict):
|
|
|
+ raise ServiceDataError("invalid_review_question", "评价问题格式无效。")
|
|
|
+ question_type = str(raw.get("type") or "").strip()
|
|
|
+ if question_type == "input":
|
|
|
+ question_type = "text"
|
|
|
+ if question_type not in {"single_choice", "multiple_choice", "text"}:
|
|
|
+ raise ServiceDataError(
|
|
|
+ "invalid_review_question_type",
|
|
|
+ "评价问题仅支持单选、多选和输入框。",
|
|
|
+ )
|
|
|
+ question_id = _clean_text(
|
|
|
+ raw.get("question_id") or uuid4().hex[:12],
|
|
|
+ max_length=32,
|
|
|
+ required=True,
|
|
|
+ )
|
|
|
+ if question_id in question_ids:
|
|
|
+ raise ServiceDataError("duplicate_review_question", "评价问题 ID 不能重复。")
|
|
|
+ question_ids.add(question_id)
|
|
|
+ question = {
|
|
|
+ "question_id": question_id,
|
|
|
+ "type": question_type,
|
|
|
+ "label": _clean_text(raw.get("label"), max_length=50, required=True),
|
|
|
+ "description": _clean_text(raw.get("description"), max_length=160),
|
|
|
+ "required": bool(raw.get("required")),
|
|
|
+ }
|
|
|
+ if question_type == "text":
|
|
|
+ question["max_length"] = max(
|
|
|
+ 50,
|
|
|
+ min(1000, int(raw.get("max_length") or 500)),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ raw_options = raw.get("options")
|
|
|
+ if not isinstance(raw_options, list) or not 2 <= len(raw_options) <= 8:
|
|
|
+ raise ServiceDataError(
|
|
|
+ "invalid_review_options",
|
|
|
+ "单选或多选问题必须设置 2 到 8 个选项。",
|
|
|
+ )
|
|
|
+ options = []
|
|
|
+ option_values: set[str] = set()
|
|
|
+ for index, raw_option in enumerate(raw_options):
|
|
|
+ if isinstance(raw_option, str):
|
|
|
+ raw_option = {"value": str(index + 1), "label": raw_option}
|
|
|
+ if not isinstance(raw_option, dict):
|
|
|
+ raise ServiceDataError(
|
|
|
+ "invalid_review_options",
|
|
|
+ "评价选项格式无效。",
|
|
|
+ )
|
|
|
+ option_value = _clean_text(
|
|
|
+ raw_option.get("value") or str(index + 1),
|
|
|
+ max_length=24,
|
|
|
+ required=True,
|
|
|
+ )
|
|
|
+ if option_value in option_values:
|
|
|
+ raise ServiceDataError(
|
|
|
+ "duplicate_review_option",
|
|
|
+ "同一问题的选项值不能重复。",
|
|
|
+ )
|
|
|
+ option_values.add(option_value)
|
|
|
+ option = {
|
|
|
+ "value": option_value,
|
|
|
+ "label": _clean_text(
|
|
|
+ raw_option.get("label"),
|
|
|
+ max_length=24,
|
|
|
+ required=True,
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ if question_type == "single_choice":
|
|
|
+ score = int(raw_option.get("score") or 0)
|
|
|
+ if not 1 <= score <= 5:
|
|
|
+ raise ServiceDataError(
|
|
|
+ "invalid_review_option_score",
|
|
|
+ "单选项评分必须在 1 到 5 之间。",
|
|
|
+ )
|
|
|
+ option["score"] = score
|
|
|
+ options.append(option)
|
|
|
+ question["options"] = options
|
|
|
+ if question_type == "single_choice":
|
|
|
+ weight = int(raw.get("weight") or 0)
|
|
|
+ if weight <= 0:
|
|
|
+ raise ServiceDataError(
|
|
|
+ "invalid_review_weight",
|
|
|
+ "单选评分问题的权重必须大于 0。",
|
|
|
+ )
|
|
|
+ question["weight"] = weight
|
|
|
+ scoring_weight += weight
|
|
|
+ questions.append(question)
|
|
|
+ if scoring_weight != 100:
|
|
|
+ raise ServiceDataError(
|
|
|
+ "invalid_review_weight",
|
|
|
+ "全部单选评分问题的权重合计必须为 100%。",
|
|
|
+ )
|
|
|
+ return {"questions": questions}
|
|
|
|
|
|
|
|
|
async def get_active_review_template() -> dict[str, Any]:
|
|
|
await ensure_service_indexes()
|
|
|
current = await reviewtemplatesdb.find_one({"status": "active"}, sort=[("version", DESCENDING)])
|
|
|
if current:
|
|
|
- return current
|
|
|
+ return {**current, **normalize_review_template(current)}
|
|
|
now = utc_now()
|
|
|
document = {"template_id": uuid4().hex, "version": 1, "status": "active", **normalize_review_template(DEFAULT_REVIEW_TEMPLATE), "created_at": now, "published_at": now}
|
|
|
try:
|
|
|
@@ -1454,22 +1622,45 @@ async def begin_review_draft(
|
|
|
and all(current.get(key) == value for key, value in identity.items())
|
|
|
and _as_utc(current["expires_at"]) > now
|
|
|
):
|
|
|
+ snapshot = current.get("template_snapshot") or {}
|
|
|
+ if "questions" not in snapshot:
|
|
|
+ normalized_snapshot = {
|
|
|
+ "template_id": snapshot.get("template_id") or template["template_id"],
|
|
|
+ "version": snapshot.get("version") or template["version"],
|
|
|
+ "questions": normalize_review_template(snapshot)["questions"],
|
|
|
+ }
|
|
|
+ question_index = int(current.get("rating_index") or 0) + int(
|
|
|
+ current.get("text_index") or 0
|
|
|
+ )
|
|
|
+ await reviewdraftsdb.update_one(
|
|
|
+ {"draft_id": current["draft_id"]},
|
|
|
+ {
|
|
|
+ "$set": {
|
|
|
+ "template_snapshot": normalized_snapshot,
|
|
|
+ "question_index": question_index,
|
|
|
+ "updated_at": now,
|
|
|
+ },
|
|
|
+ "$unset": {"rating_index": "", "text_index": ""},
|
|
|
+ },
|
|
|
+ )
|
|
|
+ current.update(
|
|
|
+ {
|
|
|
+ "template_snapshot": normalized_snapshot,
|
|
|
+ "question_index": question_index,
|
|
|
+ }
|
|
|
+ )
|
|
|
return current
|
|
|
+ normalized_template = normalize_review_template(template)
|
|
|
document = {
|
|
|
"draft_id": uuid4().hex,
|
|
|
"customer_id": int(customer_id),
|
|
|
**identity,
|
|
|
"template_snapshot": {
|
|
|
- key: template[key]
|
|
|
- for key in (
|
|
|
- "template_id",
|
|
|
- "version",
|
|
|
- "rating_questions",
|
|
|
- "text_questions",
|
|
|
- )
|
|
|
+ "template_id": template["template_id"],
|
|
|
+ "version": template["version"],
|
|
|
+ "questions": normalized_template["questions"],
|
|
|
},
|
|
|
- "rating_index": 0,
|
|
|
- "text_index": 0,
|
|
|
+ "question_index": 0,
|
|
|
"answers": {},
|
|
|
"created_at": now,
|
|
|
"updated_at": now,
|
|
|
@@ -1485,16 +1676,21 @@ async def save_review_draft_progress(
|
|
|
customer_id: int,
|
|
|
*,
|
|
|
answers: dict[str, Any],
|
|
|
- rating_index: int,
|
|
|
- text_index: int,
|
|
|
+ question_index: int | None = None,
|
|
|
+ rating_index: int = 0,
|
|
|
+ text_index: int = 0,
|
|
|
) -> None:
|
|
|
+ current_index = (
|
|
|
+ max(0, int(question_index))
|
|
|
+ if question_index is not None
|
|
|
+ else max(0, int(rating_index) + int(text_index))
|
|
|
+ )
|
|
|
await reviewdraftsdb.update_one(
|
|
|
{"customer_id": int(customer_id)},
|
|
|
{
|
|
|
"$set": {
|
|
|
"answers": dict(answers),
|
|
|
- "rating_index": max(0, int(rating_index)),
|
|
|
- "text_index": max(0, int(text_index)),
|
|
|
+ "question_index": current_index,
|
|
|
"updated_at": utc_now(),
|
|
|
}
|
|
|
},
|
|
|
@@ -1505,27 +1701,51 @@ async def delete_review_draft(customer_id: int) -> None:
|
|
|
await reviewdraftsdb.delete_one({"customer_id": int(customer_id)})
|
|
|
|
|
|
|
|
|
-def _score_review(template: dict[str, Any], answers: dict[str, Any]) -> tuple[float, dict[str, int], dict[str, str]]:
|
|
|
+def _score_review(
|
|
|
+ template: dict[str, Any],
|
|
|
+ answers: dict[str, Any],
|
|
|
+) -> tuple[float, dict[str, int], dict[str, Any], dict[str, str]]:
|
|
|
+ questions = normalize_review_template(template)["questions"]
|
|
|
ratings: dict[str, int] = {}
|
|
|
+ choices: dict[str, Any] = {}
|
|
|
texts: dict[str, str] = {}
|
|
|
total = 0.0
|
|
|
- for question in template["rating_questions"]:
|
|
|
+ for question in questions:
|
|
|
raw = answers.get(question["question_id"])
|
|
|
- if raw in (None, "") and not question["required"]:
|
|
|
+ if question["type"] == "text":
|
|
|
+ text = _clean_text(
|
|
|
+ raw,
|
|
|
+ max_length=question["max_length"],
|
|
|
+ required=question["required"],
|
|
|
+ )
|
|
|
+ if text:
|
|
|
+ texts[question["question_id"]] = text
|
|
|
continue
|
|
|
- try:
|
|
|
- score = int(raw)
|
|
|
- except (TypeError, ValueError) as exc:
|
|
|
- raise ServiceDataError("invalid_review_answer", "评分必须是 1 到 5 的整数。") from exc
|
|
|
- if not 1 <= score <= 5:
|
|
|
- raise ServiceDataError("invalid_review_answer", "评分必须在 1 到 5 之间。")
|
|
|
- ratings[question["question_id"]] = score
|
|
|
- total += score * question["weight"] / 100
|
|
|
- for question in template["text_questions"]:
|
|
|
- text = _clean_text(answers.get(question["question_id"]), max_length=question["max_length"], required=question["required"])
|
|
|
- if text:
|
|
|
- texts[question["question_id"]] = text
|
|
|
- return round(total, 4), ratings, texts
|
|
|
+ options = {
|
|
|
+ str(option["value"]): option for option in question["options"]
|
|
|
+ }
|
|
|
+ if question["type"] == "single_choice":
|
|
|
+ if raw in (None, "") and not question["required"]:
|
|
|
+ continue
|
|
|
+ option = options.get(str(raw))
|
|
|
+ if not option:
|
|
|
+ raise ServiceDataError("invalid_review_answer", "请选择有效的单选项。")
|
|
|
+ score = int(option["score"])
|
|
|
+ ratings[question["question_id"]] = score
|
|
|
+ choices[question["question_id"]] = option["label"]
|
|
|
+ total += score * question["weight"] / 100
|
|
|
+ continue
|
|
|
+ selected = raw if isinstance(raw, list) else ([] if raw in (None, "") else [raw])
|
|
|
+ selected_values = list(dict.fromkeys(str(item) for item in selected))
|
|
|
+ if question["required"] and not selected_values:
|
|
|
+ raise ServiceDataError("required_field", f"{question['label']} 为必选项。")
|
|
|
+ if set(selected_values) - set(options):
|
|
|
+ raise ServiceDataError("invalid_review_answer", "请选择有效的多选项。")
|
|
|
+ if selected_values:
|
|
|
+ choices[question["question_id"]] = [
|
|
|
+ options[value]["label"] for value in selected_values
|
|
|
+ ]
|
|
|
+ return round(total, 4), ratings, choices, texts
|
|
|
|
|
|
|
|
|
async def submit_review(
|
|
|
@@ -1563,11 +1783,26 @@ async def submit_review(
|
|
|
package_snapshot = next((item for item in service_profile.get("packages", []) if str(item.get("package_id")) == str(package_id)), None)
|
|
|
if package_id and not package_snapshot:
|
|
|
raise ServiceDataError("package_not_found", "未找到所选套餐。")
|
|
|
- package_snapshot = package_snapshot or {"package_id": None, "name": "其他服务", "category": "其他"}
|
|
|
+ if not package_snapshot:
|
|
|
+ packages = service_profile.get("packages") or []
|
|
|
+ primary_package = packages[0] if packages else {}
|
|
|
+ package_snapshot = {
|
|
|
+ "package_id": None,
|
|
|
+ "name": "技师服务",
|
|
|
+ "category": primary_package.get("category") or "其他",
|
|
|
+ }
|
|
|
template = template_snapshot or await get_active_review_template()
|
|
|
- score, rating_answers, text_answers = _score_review(template, answers)
|
|
|
+ normalized_template = normalize_review_template(template)
|
|
|
+ score, rating_answers, choice_answers, text_answers = _score_review(
|
|
|
+ normalized_template,
|
|
|
+ answers,
|
|
|
+ )
|
|
|
normalized_content = "\n".join(
|
|
|
- value.casefold() for _, value in sorted(text_answers.items()) if value
|
|
|
+ str(value).casefold()
|
|
|
+ for _, value in sorted(
|
|
|
+ {**choice_answers, **text_answers}.items()
|
|
|
+ )
|
|
|
+ if value
|
|
|
)
|
|
|
now = utc_now()
|
|
|
document = {
|
|
|
@@ -1579,8 +1814,13 @@ async def submit_review(
|
|
|
"source": source,
|
|
|
"package_snapshot": package_snapshot,
|
|
|
"category": package_snapshot.get("category") or "其他",
|
|
|
- "template_snapshot": {key: template[key] for key in ("template_id", "version", "rating_questions", "text_questions")},
|
|
|
+ "template_snapshot": {
|
|
|
+ "template_id": template["template_id"],
|
|
|
+ "version": template["version"],
|
|
|
+ "questions": normalized_template["questions"],
|
|
|
+ },
|
|
|
"rating_answers": rating_answers,
|
|
|
+ "choice_answers": choice_answers,
|
|
|
"text_answers": text_answers,
|
|
|
"content_fingerprint": (
|
|
|
hashlib.sha256(normalized_content.encode()).hexdigest()
|
|
|
@@ -1659,7 +1899,7 @@ async def recompute_review_stats() -> None:
|
|
|
"average_score": round(average, 4),
|
|
|
"category_average": round(category_average, 4),
|
|
|
"rank_score": round(rank_score, 6),
|
|
|
- "eligible": count >= 3,
|
|
|
+ "eligible": count >= 1,
|
|
|
"dimension_averages": {key: round(sum(items) / len(items), 4) for key, items in dimensions.items()},
|
|
|
"last_review_at": max(item.get("moderated_at") or item["created_at"] for item in values),
|
|
|
"updated_at": now,
|
|
|
@@ -1671,7 +1911,7 @@ async def recompute_review_stats() -> None:
|
|
|
|
|
|
async def list_leaderboard(*, category: str = "", page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
|
|
|
await ensure_service_indexes()
|
|
|
- filters: dict[str, Any] = {"eligible": True}
|
|
|
+ filters: dict[str, Any] = {"review_count": {"$gte": 1}}
|
|
|
if category:
|
|
|
filters["category"] = str(category)
|
|
|
visible_ids = [
|
|
|
@@ -1720,13 +1960,107 @@ async def list_public_reviews(
|
|
|
"category": item.get("category"),
|
|
|
"score": item.get("score"),
|
|
|
"rating_answers": item.get("rating_answers", {}),
|
|
|
+ "choice_answers": item.get("choice_answers", {}),
|
|
|
"text_answers": item.get("text_answers", {}),
|
|
|
+ "template_snapshot": item.get("template_snapshot", {}),
|
|
|
"approved_at": item.get("moderated_at"),
|
|
|
+ "topic_url": item.get("topic_url"),
|
|
|
}
|
|
|
)
|
|
|
return public_items, total
|
|
|
|
|
|
|
|
|
+async def get_technician_review_topic(
|
|
|
+ technician_id: int,
|
|
|
+) -> dict[str, Any] | None:
|
|
|
+ await ensure_service_indexes()
|
|
|
+ return await reviewtopicsdb.find_one(
|
|
|
+ {"technician_id": int(technician_id), "status": "active"}
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+async def save_technician_review_topic(
|
|
|
+ *,
|
|
|
+ technician_id: int,
|
|
|
+ forum_chat_id: str,
|
|
|
+ forum_username: str,
|
|
|
+ message_thread_id: int,
|
|
|
+ topic_name: str,
|
|
|
+ topic_url: str,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ await ensure_service_indexes()
|
|
|
+ now = utc_now()
|
|
|
+ await reviewtopicsdb.update_one(
|
|
|
+ {"technician_id": int(technician_id)},
|
|
|
+ {
|
|
|
+ "$set": {
|
|
|
+ "forum_chat_id": str(forum_chat_id),
|
|
|
+ "forum_username": str(forum_username),
|
|
|
+ "message_thread_id": int(message_thread_id),
|
|
|
+ "topic_name": _clean_text(
|
|
|
+ topic_name,
|
|
|
+ max_length=128,
|
|
|
+ required=True,
|
|
|
+ ),
|
|
|
+ "topic_url": _clean_text(
|
|
|
+ topic_url,
|
|
|
+ max_length=300,
|
|
|
+ required=True,
|
|
|
+ ),
|
|
|
+ "status": "active",
|
|
|
+ "updated_at": now,
|
|
|
+ },
|
|
|
+ "$setOnInsert": {
|
|
|
+ "technician_id": int(technician_id),
|
|
|
+ "created_at": now,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ upsert=True,
|
|
|
+ )
|
|
|
+ return await get_technician_review_topic(int(technician_id)) or {}
|
|
|
+
|
|
|
+
|
|
|
+async def mark_review_topic_published(
|
|
|
+ review_id: str,
|
|
|
+ *,
|
|
|
+ topic: dict[str, Any],
|
|
|
+ message_id: int,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ updated = await reviewsdb.find_one_and_update(
|
|
|
+ {"review_id": str(review_id), "status": "approved"},
|
|
|
+ {
|
|
|
+ "$set": {
|
|
|
+ "topic_url": topic["topic_url"],
|
|
|
+ "topic_message_id": int(message_id),
|
|
|
+ "topic_published_at": utc_now(),
|
|
|
+ "updated_at": utc_now(),
|
|
|
+ },
|
|
|
+ "$unset": {"topic_publish_error": ""},
|
|
|
+ },
|
|
|
+ return_document=ReturnDocument.AFTER,
|
|
|
+ )
|
|
|
+ return updated or {}
|
|
|
+
|
|
|
+
|
|
|
+async def get_review(review_id: str) -> dict[str, Any] | None:
|
|
|
+ return await reviewsdb.find_one({"review_id": str(review_id)})
|
|
|
+
|
|
|
+
|
|
|
+async def mark_review_topic_publish_error(
|
|
|
+ review_id: str,
|
|
|
+ error: str,
|
|
|
+) -> None:
|
|
|
+ await reviewsdb.update_one(
|
|
|
+ {"review_id": str(review_id), "status": "approved"},
|
|
|
+ {
|
|
|
+ "$set": {
|
|
|
+ "topic_publish_error": _clean_text(error, max_length=300),
|
|
|
+ "updated_at": utc_now(),
|
|
|
+ }
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
async def get_technician_review_summary(technician_id: int) -> dict[str, Any]:
|
|
|
items = await reviewsdb.find(
|
|
|
{"technician_id": int(technician_id), "status": "approved"},
|