Bladeren bron

feat: add technician service fulfillment flow

AI-Co-Authored-By: Codex
chendeben 2 maanden geleden
bovenliggende
commit
dd88f06d8f

+ 24 - 0
README.md

@@ -6,6 +6,30 @@
     Telegram Group Manager Bot + Userbot Written In Python Using Pyrogram.
 </h3>
 
+## 技师服务最小履约闭环
+
+当前版本要求 Python 3.12,并提供技师展示、附近查找、服务请求、最终报价、
+状态流转、完成评价二维码、主动评价、审核评价排行和对应管理后台。
+首期不处理支付、退款或预约时间。
+
+启用服务地址前必须配置独立 Fernet 密钥:
+
+```console
+uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+```
+
+将输出写入 `SERVICE_ADDRESS_ENCRYPTION_KEY`。精确地址不会进入二维码、评价或
+普通接口,服务单进入终态 7 天后由后台任务自动脱敏。常用顾客/技师命令:
+
+- `/find_technician`:查找附近技师并发起服务请求。
+- `/my_services`:查看服务单并继续报价、确认、开始、取消或争议流程。
+- `/technician_profile`:技师选择后台模板或自建套餐并发布服务资料。
+- `/review_qr`:技师为服务中的订单生成完成确认二维码。
+- `/review_teacher`:顾客主动评价技师。
+- `/technician_top`:查看审核评价排行。
+
+旧 `/teacher_*` 命令继续作为兼容别名。
+
 <p align="center">
     <a href="https://python.org">
         <img src="http://forthebadge.com/images/badges/made-with-python.svg" alt="made-with-python">

+ 1 - 0
admin-web/.umirc.ts

@@ -20,6 +20,7 @@ export default defineConfig({
         { path: '/giveaways', component: '@/pages/Giveaways' },
         { path: '/points', component: '@/pages/Points' },
         { path: '/directory', component: '@/pages/Directory' },
+        { path: '/services', component: '@/pages/Services' },
         { path: '/audit', component: '@/pages/AuditLogs' },
         { path: '/settings', component: '@/pages/Settings' },
       ],

+ 8 - 2
admin-web/src/layouts/AdminLayout.tsx

@@ -13,6 +13,7 @@ import {
   Settings,
   Star,
   Users,
+  Wrench,
 } from 'lucide-react';
 import { Outlet, history, useLocation } from '@umijs/max';
 import { useEffect, useState } from 'react';
@@ -32,7 +33,8 @@ const menuItems = [
   { path: '/chats', name: '群组管理', icon: <Users size={18} /> },
   { path: '/giveaways', name: '抽奖管理', icon: <Gift size={18} /> },
   { path: '/points', name: '积分管理', icon: <Star size={18} /> },
-  { path: '/directory', name: '老师与附近', icon: <MapPinned size={18} /> },
+  { path: '/directory', name: '技师与附近', icon: <MapPinned size={18} /> },
+  { path: '/services', name: '技师服务', icon: <Wrench size={18} /> },
   { path: '/audit', name: '操作日志', icon: <ClipboardList size={18} /> },
   { path: '/settings', name: '系统设置', icon: <Settings size={18} /> },
 ];
@@ -99,7 +101,9 @@ export default function AdminLayout() {
     if (item.path === '/chats') return hasAnyPermission(chatPermissions);
     if (item.path === '/points') return hasPermission('points.manage');
     if (item.path === '/giveaways') return hasPermission('giveaways.manage');
-    if (item.path === '/directory') return hasPermission('teacher_directory.manage');
+    if (item.path === '/directory' || item.path === '/services') {
+      return hasPermission('teacher_directory.manage');
+    }
     return true;
   });
 
@@ -112,6 +116,8 @@ export default function AdminLayout() {
         !hasPermission('giveaways.manage')) ||
       (location.pathname.startsWith('/directory') &&
         !hasPermission('teacher_directory.manage')) ||
+      (location.pathname.startsWith('/services') &&
+        !hasPermission('teacher_directory.manage')) ||
       (location.pathname.startsWith('/chats') &&
         !hasAnyPermission(chatPermissions));
     if (denied) history.replace('/dashboard');

+ 27 - 10
admin-web/src/pages/Directory.tsx

@@ -61,10 +61,10 @@ const applicationColors: Record<DirectoryApplicationStatus, string> = {
 const eventLabels: Record<string, string> = {
   location_updated: '更新位置',
   location_cleared: '清除位置',
-  teacher_application_submitted: '提交老师申请',
-  teacher_application_approve: '批准老师',
+  teacher_application_submitted: '提交技师申请',
+  teacher_application_approve: '批准技师',
   teacher_application_reject: '拒绝申请',
-  teacher_application_revoke: '撤销老师',
+  teacher_application_revoke: '撤销技师',
   teacher_list: '上榜',
   teacher_unlist: '下榜',
   teacher_online: '上线',
@@ -178,15 +178,15 @@ export default function DirectoryPage() {
 
   const approve = (item: DirectoryProfile) => {
     Modal.confirm({
-      title: `批准 ${item.display_name || item.user_id} 成为老师?`,
-      content: '批准后老师身份、位置和状态将在全部群和全部 Bot 中生效。',
+      title: `批准 ${item.display_name || item.user_id} 成为技师?`,
+      content: '批准后技师身份、位置和状态将在全部群和全部 Bot 中生效。',
       okText: '确认批准',
       onOk: async () => {
         await apiRequest(
           `/directory/applications/${item.user_id}/actions`,
           jsonOptions('POST', { action: 'approve', confirm: true }),
         );
-        message.success('老师申请已批准');
+        message.success('技师申请已批准');
         await refresh();
       },
     });
@@ -257,6 +257,23 @@ export default function DirectoryPage() {
           ? locationSourceLabels[item.location.source || ''] || '已更新'
           : '尚未更新',
     },
+    {
+      title: '服务资料',
+      minWidth: 170,
+      render: (_, item) =>
+        item.service_profile?.is_complete ? (
+          <Space direction="vertical" size={0}>
+            <Tag color={item.service_profile.accepting_requests ? 'success' : 'warning'}>
+              {item.service_profile.accepting_requests ? '接单中' : '暂停接单'}
+            </Tag>
+            <Typography.Text type="secondary">
+              {item.service_profile.packages.length} 个套餐
+            </Typography.Text>
+          </Space>
+        ) : (
+          <Tag>未完善</Tag>
+        ),
+    },
     {
       title: '申请时间',
       dataIndex: 'applied_at',
@@ -494,7 +511,7 @@ export default function DirectoryPage() {
   return (
     <>
       <PageHeader
-        title="老师与附近"
+        title="技师与附近"
         actions={
           <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void refresh()}>
             刷新
@@ -549,7 +566,7 @@ export default function DirectoryPage() {
             },
             {
               key: 'teachers',
-              label: `老师目录 (${profiles.total})`,
+              label: `技师目录 (${profiles.total})`,
               children: (
                 <>
                   {searchToolbar}
@@ -565,7 +582,7 @@ export default function DirectoryPage() {
                       showSizeChanger: false,
                     }}
                     onChange={(pagination) => void loadProfiles(pagination.current || 1)}
-                    scroll={{ x: 1150 }}
+                    scroll={{ x: 1320 }}
                   />
                 </>
               ),
@@ -627,7 +644,7 @@ export default function DirectoryPage() {
                   onFinish={(values) => {
                     Modal.confirm({
                       title: '确认更新全平台目录设置?',
-                      content: '设置会影响所有具备“老师与附近”职责的机器人。',
+                      content: '设置会影响所有具备“技师与附近”职责的机器人。',
                       okText: '确认保存',
                       onOk: async () => {
                         const saved = await apiRequest<DirectorySettings>(

+ 781 - 0
admin-web/src/pages/Services.tsx

@@ -0,0 +1,781 @@
+import {
+  Alert,
+  Button,
+  Card,
+  Col,
+  Descriptions,
+  Divider,
+  Drawer,
+  Form,
+  Input,
+  InputNumber,
+  Modal,
+  Row,
+  Select,
+  Space,
+  Statistic,
+  Table,
+  Tabs,
+  Tag,
+  Typography,
+  message,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import dayjs from 'dayjs';
+import { Copy, Eye, Plus, RefreshCw, ShieldBan, Trash2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+import { PageHeader, Surface } from '@/components/Page';
+import { apiRequest, jsonOptions } from '@/services/api';
+import type {
+  FulfillmentMetrics,
+  Paged,
+  ServiceCustomerBlock,
+  ServiceLeaderboardItem,
+  ServiceOrder,
+  ServiceOrderStatus,
+  ServicePackageTemplate,
+  ServiceReview,
+  ServiceReviewQr,
+  ServiceReviewTemplate,
+  ServiceReport,
+  ServiceSettings,
+} from '@/types';
+
+const emptyPage = <T,>(): Paged<T> => ({ items: [], total: 0, page: 1, page_size: 20 });
+
+const statusLabels: Record<ServiceOrderStatus, string> = {
+  requested: '待处理',
+  quoted: '待确认报价',
+  confirmed: '已确认',
+  in_progress: '服务中',
+  completion_pending: '待扫码确认',
+  completed: '已完成',
+  rejected: '已拒绝',
+  canceled_customer: '顾客取消',
+  canceled_technician: '技师取消',
+  disputed: '争议中',
+  voided: '已作废',
+};
+
+const statusColors: Record<ServiceOrderStatus, string> = {
+  requested: 'processing',
+  quoted: 'warning',
+  confirmed: 'cyan',
+  in_progress: 'blue',
+  completion_pending: 'purple',
+  completed: 'success',
+  rejected: 'error',
+  canceled_customer: 'default',
+  canceled_technician: 'default',
+  disputed: 'volcano',
+  voided: 'default',
+};
+
+type TemplateFormValue = {
+  template_name: string;
+  admin_note?: string;
+  sort_order?: number;
+  name: string;
+  category: string;
+  description: string;
+  tags?: string;
+  service_modes: Array<'at_store' | 'onsite'>;
+  price_mode: 'fixed' | 'starting_at' | 'range' | 'negotiable';
+  currency: string;
+  price_unit: 'per_service' | 'per_hour' | 'per_item' | 'per_visit';
+  min_price?: string;
+  max_price?: string;
+  duration_minutes?: number;
+  service_radius_km?: number;
+  included_items?: string;
+  excluded_items?: string;
+  preparation?: string;
+  out_of_range_policy?: string;
+  travel_fee_mode: 'included' | 'fixed' | 'per_km' | 'quoted';
+  travel_fee_amount?: string;
+  travel_fee_per_km?: string;
+  addons?: Array<{
+    addon_id?: string;
+    name: string;
+    unit: string;
+    price: string;
+    extra_minutes?: number;
+  }>;
+};
+
+export default function ServicesPage() {
+  const [loading, setLoading] = useState(false);
+  const [metrics, setMetrics] = useState<FulfillmentMetrics | null>(null);
+  const [settings, setSettings] = useState<ServiceSettings | null>(null);
+  const [templates, setTemplates] = useState<ServicePackageTemplate[]>([]);
+  const [orders, setOrders] = useState<Paged<ServiceOrder>>(emptyPage());
+  const [reviews, setReviews] = useState<Paged<ServiceReview>>(emptyPage());
+  const [qrs, setQrs] = useState<Paged<ServiceReviewQr>>(emptyPage());
+  const [leaderboard, setLeaderboard] = useState<Paged<ServiceLeaderboardItem>>(emptyPage());
+  const [customerBlocks, setCustomerBlocks] = useState<Paged<ServiceCustomerBlock>>(emptyPage());
+  const [reports, setReports] = useState<Paged<ServiceReport>>(emptyPage());
+  const [reviewTemplate, setReviewTemplate] = useState<ServiceReviewTemplate | null>(null);
+  const [orderStatus, setOrderStatus] = useState('');
+  const [reviewStatus, setReviewStatus] = useState('pending');
+  const [query, setQuery] = useState('');
+  const [detail, setDetail] = useState<ServiceOrder | null>(null);
+  const [templateTarget, setTemplateTarget] = useState<ServicePackageTemplate | null>(null);
+  const [templateOpen, setTemplateOpen] = useState(false);
+  const [orderAction, setOrderAction] = useState<{ order: ServiceOrder; action: 'resume' | 'void' } | null>(null);
+  const [addressTarget, setAddressTarget] = useState<ServiceOrder | null>(null);
+  const [reviewAction, setReviewAction] = useState<{ review: ServiceReview; action: 'reject' | 'void' } | null>(null);
+  const [blockOpen, setBlockOpen] = useState(false);
+  const [reportTarget, setReportTarget] = useState<{ report: ServiceReport; action: 'resolve' | 'dismiss' } | null>(null);
+  const [settingsForm] = Form.useForm<ServiceSettings>();
+  const [templateForm] = Form.useForm<TemplateFormValue>();
+  const [reviewTemplateForm] = Form.useForm<Pick<ServiceReviewTemplate, 'rating_questions' | 'text_questions'>>();
+  const [orderReasonForm] = Form.useForm<{ reason: string }>();
+  const [reviewReasonForm] = Form.useForm<{ reason: string }>();
+  const [addressForm] = Form.useForm<{ reason: string }>();
+  const [blockForm] = Form.useForm<{ customer_id: string; reason: string }>();
+  const [reportForm] = Form.useForm<{ reason: string }>();
+
+  const loadOrders = async (page = 1) => {
+    const params = new URLSearchParams({ page: String(page), page_size: '20', status: orderStatus, query });
+    setOrders(await apiRequest<Paged<ServiceOrder>>(`/directory/service-orders?${params}`));
+  };
+
+  const loadReviews = async (page = 1) => {
+    const params = new URLSearchParams({ page: String(page), page_size: '20', status: reviewStatus, query });
+    setReviews(await apiRequest<Paged<ServiceReview>>(`/directory/reviews?${params}`));
+  };
+
+  const refresh = async () => {
+    setLoading(true);
+    try {
+      const [metricsValue, settingsValue, templateList, qrList, rankList, activeReviewTemplate, blockList, reportList] = await Promise.all([
+        apiRequest<FulfillmentMetrics>('/directory/fulfillment-metrics'),
+        apiRequest<ServiceSettings>('/directory/service-settings'),
+        apiRequest<{ items: ServicePackageTemplate[] }>('/directory/package-templates?include_archived=true'),
+        apiRequest<Paged<ServiceReviewQr>>('/directory/review-qrs?page=1&page_size=20'),
+        apiRequest<Paged<ServiceLeaderboardItem>>('/directory/leaderboard?page=1&page_size=20'),
+        apiRequest<ServiceReviewTemplate>('/directory/review-template'),
+        apiRequest<Paged<ServiceCustomerBlock>>('/directory/customer-blocks?scope=global&active=true&page=1&page_size=20'),
+        apiRequest<Paged<ServiceReport>>('/directory/reports?status=open&page=1&page_size=20'),
+      ]);
+      setMetrics(metricsValue);
+      setSettings(settingsValue);
+      settingsForm.setFieldsValue(settingsValue);
+      setTemplates(templateList.items);
+      setQrs(qrList);
+      setLeaderboard(rankList);
+      setCustomerBlocks(blockList);
+      setReports(reportList);
+      setReviewTemplate(activeReviewTemplate);
+      reviewTemplateForm.setFieldsValue({
+        rating_questions: activeReviewTemplate.rating_questions,
+        text_questions: activeReviewTemplate.text_questions,
+      });
+      await Promise.all([loadOrders(orders.page), loadReviews(reviews.page)]);
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '履约数据加载失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    void refresh();
+  }, []);
+
+  const saveSettings = async (values: ServiceSettings) => {
+    const saved = await apiRequest<ServiceSettings>(
+      '/directory/service-settings',
+      jsonOptions('PUT', { ...values, confirm: true }),
+    );
+    setSettings(saved);
+    settingsForm.setFieldsValue(saved);
+    message.success('履约设置已更新');
+  };
+
+  const openTemplate = (item?: ServicePackageTemplate) => {
+    setTemplateTarget(item || null);
+    setTemplateOpen(true);
+    if (!item) {
+      templateForm.setFieldsValue({
+        service_modes: ['at_store'],
+        price_mode: 'fixed',
+        currency: 'CNY',
+        price_unit: 'per_service',
+        travel_fee_mode: 'quoted',
+        sort_order: 0,
+      });
+      return;
+    }
+    const value = item.package;
+    templateForm.setFieldsValue({
+      template_name: item.name,
+      admin_note: item.admin_note,
+      sort_order: item.sort_order,
+      name: value.name,
+      category: value.category,
+      description: value.description,
+      tags: value.tags.join(','),
+      service_modes: value.service_modes,
+      price_mode: value.price_mode,
+      currency: value.currency,
+      price_unit: value.price_unit,
+      min_price: value.min_price,
+      max_price: value.max_price,
+      duration_minutes: value.duration_minutes || undefined,
+      service_radius_km: value.service_radius_km || undefined,
+      included_items: value.included_items,
+      excluded_items: value.excluded_items,
+      preparation: value.preparation,
+      out_of_range_policy: value.out_of_range_policy,
+      travel_fee_mode: value.travel_fee.mode,
+      travel_fee_amount: value.travel_fee.amount,
+      travel_fee_per_km: value.travel_fee.per_km,
+      addons: value.addons,
+    });
+  };
+
+  const saveTemplate = async (values: TemplateFormValue) => {
+    const payload = {
+      template_name: values.template_name,
+      admin_note: values.admin_note,
+      sort_order: values.sort_order,
+      confirm: true,
+      package: {
+        name: values.name,
+        category: values.category,
+        description: values.description,
+        tags: values.tags || '',
+        service_modes: values.service_modes,
+        price_mode: values.price_mode,
+        currency: values.currency,
+        price_unit: values.price_unit,
+        min_price: values.min_price || '0',
+        max_price: values.max_price || values.min_price || '0',
+        duration_minutes: values.duration_minutes,
+        service_radius_km: values.service_modes.includes('onsite') ? values.service_radius_km : null,
+        included_items: values.included_items,
+        excluded_items: values.excluded_items,
+        preparation: values.preparation,
+        out_of_range_policy: values.out_of_range_policy,
+        travel_fee: {
+          mode: values.travel_fee_mode,
+          amount: values.travel_fee_amount || '0',
+          per_km: values.travel_fee_per_km || '0',
+        },
+        addons: (values.addons || []).map((addon) => ({
+          ...addon,
+          extra_minutes: addon.extra_minutes || 0,
+        })),
+      },
+    };
+    await apiRequest(
+      templateTarget
+        ? `/directory/package-templates/${templateTarget.template_id}`
+        : '/directory/package-templates',
+      jsonOptions(templateTarget ? 'PUT' : 'POST', payload),
+    );
+    setTemplateOpen(false);
+    templateForm.resetFields();
+    message.success(templateTarget ? '套餐模板已创建新版本' : '套餐模板已创建');
+    await refresh();
+  };
+
+  const templateAction = (item: ServicePackageTemplate, action: string) => {
+    Modal.confirm({
+      title: action === 'archive' ? '归档套餐模板?' : `确认执行 ${action}?`,
+      content: '模板操作不会同步、提醒或覆盖技师已发布套餐和历史服务单快照。',
+      okText: '确认执行',
+      onOk: async () => {
+        await apiRequest(
+          `/directory/package-templates/${item.template_id}/actions`,
+          jsonOptions('POST', { action, confirm: true }),
+        );
+        message.success('模板状态已更新');
+        await refresh();
+      },
+    });
+  };
+
+  const showOrder = async (order: ServiceOrder) => {
+    setDetail(await apiRequest<ServiceOrder>(`/directory/service-orders/${order.order_id}`));
+  };
+
+  const submitOrderAction = async ({ reason }: { reason: string }) => {
+    if (!orderAction) return;
+    await apiRequest(
+      `/directory/service-orders/${orderAction.order.order_id}/actions`,
+      jsonOptions('POST', { action: orderAction.action, reason, confirm: true }),
+    );
+    setOrderAction(null);
+    orderReasonForm.resetFields();
+    message.success('服务单状态已更新');
+    await refresh();
+  };
+
+  const revealAddress = async ({ reason }: { reason: string }) => {
+    if (!addressTarget) return;
+    const address = await apiRequest<{ kind: string; address_text: string; longitude: number; latitude: number }>(
+      `/directory/service-orders/${addressTarget.order_id}/address`,
+      jsonOptions('POST', { reason, confirm: true }),
+    );
+    setAddressTarget(null);
+    addressForm.resetFields();
+    Modal.info({
+      title: '精确履约地址(敏感信息)',
+      content: (
+        <Space direction="vertical">
+          <Typography.Text>{address.address_text}</Typography.Text>
+          <Typography.Text type="secondary" className="numeric">
+            {address.latitude}, {address.longitude}
+          </Typography.Text>
+          <Typography.Text type="warning">本次查看原因和管理员账号已记录审计。</Typography.Text>
+        </Space>
+      ),
+    });
+  };
+
+  const approveReview = (review: ServiceReview) => {
+    Modal.confirm({
+      title: '批准并计入审核评价排行?',
+      content: `评分 ${review.score.toFixed(2)};来源为${review.source === 'qr_verified' ? '完成服务单评价' : '用户主动评价'}。`,
+      okText: '确认批准',
+      onOk: async () => {
+        await apiRequest(
+          `/directory/reviews/${review.review_id}/actions`,
+          jsonOptions('POST', { action: 'approve', confirm: true }),
+        );
+        message.success('评价已批准并重算排行');
+        await refresh();
+      },
+    });
+  };
+
+  const submitReviewAction = async ({ reason }: { reason: string }) => {
+    if (!reviewAction) return;
+    await apiRequest(
+      `/directory/reviews/${reviewAction.review.review_id}/actions`,
+      jsonOptions('POST', { action: reviewAction.action, reason, confirm: true }),
+    );
+    setReviewAction(null);
+    reviewReasonForm.resetFields();
+    message.success('评价状态已更新并重算排行');
+    await refresh();
+  };
+
+  const publishReviewTemplate = async (values: Pick<ServiceReviewTemplate, 'rating_questions' | 'text_questions'>) => {
+    const published = await apiRequest<ServiceReviewTemplate>(
+      '/directory/review-template',
+      jsonOptions('PUT', { ...values, confirm: true }),
+    );
+    setReviewTemplate(published);
+    message.success(`评价模板 V${published.version} 已发布,仅影响后续评价`);
+  };
+
+  const blockCustomer = async ({ customer_id, reason }: { customer_id: string; reason: string }) => {
+    await apiRequest(
+      `/directory/customer-blocks/${customer_id}`,
+      jsonOptions('POST', { active: true, reason, confirm: true }),
+    );
+    setBlockOpen(false);
+    blockForm.resetFields();
+    message.success('顾客已全局封禁');
+    await refresh();
+  };
+
+  const unblockCustomer = (item: ServiceCustomerBlock) => {
+    Modal.confirm({
+      title: `解除顾客 ${item.customer_id} 的全局封禁?`,
+      okText: '确认解除',
+      onOk: async () => {
+        await apiRequest(
+          `/directory/customer-blocks/${item.customer_id}`,
+          jsonOptions('POST', { active: false, reason: '管理员解除全局封禁', confirm: true }),
+        );
+        message.success('顾客封禁已解除');
+        await refresh();
+      },
+    });
+  };
+
+  const submitReportAction = async ({ reason }: { reason: string }) => {
+    if (!reportTarget) return;
+    await apiRequest(
+      `/directory/reports/${reportTarget.report.report_id}/actions`,
+      jsonOptions('POST', { action: reportTarget.action, reason, confirm: true }),
+    );
+    setReportTarget(null);
+    reportForm.resetFields();
+    message.success('举报已处理');
+    await refresh();
+  };
+
+  const templateColumns: ColumnsType<ServicePackageTemplate> = [
+    {
+      title: '模板',
+      minWidth: 220,
+      render: (_, item) => (
+        <Space direction="vertical" size={0}>
+          <Typography.Text strong>{item.name}</Typography.Text>
+          <Typography.Text type="secondary">{item.package.category} · V{item.version}</Typography.Text>
+        </Space>
+      ),
+    },
+    {
+      title: '方式 / 参考价',
+      minWidth: 220,
+      render: (_, item) => (
+        <Space wrap>
+          {item.package.service_modes.map((mode) => <Tag key={mode}>{mode === 'at_store' ? '到店' : '上门'}</Tag>)}
+          <Typography.Text>{item.package.currency} {item.package.min_price} - {item.package.max_price}</Typography.Text>
+        </Space>
+      ),
+    },
+    { title: '排序', dataIndex: 'sort_order', width: 80 },
+    { title: '状态', dataIndex: 'status', width: 100, render: (value) => <Tag color={value === 'enabled' ? 'success' : value === 'archived' ? 'default' : 'warning'}>{value === 'enabled' ? '启用' : value === 'archived' ? '已归档' : '停用'}</Tag> },
+    {
+      title: '操作',
+      width: 300,
+      fixed: 'right',
+      render: (_, item) => (
+        <Space wrap>
+          <Button size="small" onClick={() => openTemplate(item)}>编辑</Button>
+          <Button size="small" icon={<Copy size={14} />} onClick={() => templateAction(item, 'duplicate')}>复制</Button>
+          {item.status !== 'archived' ? (
+            <Button size="small" onClick={() => templateAction(item, item.status === 'enabled' ? 'disable' : 'enable')}>
+              {item.status === 'enabled' ? '停用' : '启用'}
+            </Button>
+          ) : null}
+          {item.status !== 'archived' ? <Button size="small" danger onClick={() => templateAction(item, 'archive')}>归档</Button> : null}
+        </Space>
+      ),
+    },
+  ];
+
+  const orderColumns: ColumnsType<ServiceOrder> = [
+    { title: '服务单', dataIndex: 'order_id', width: 120, render: (value) => <Typography.Text className="numeric">{value.slice(0, 8)}</Typography.Text> },
+    { title: '技师 / 顾客', minWidth: 220, render: (_, item) => <Space direction="vertical" size={0}><Typography.Text>{item.technician_name} · {item.technician_id}</Typography.Text><Typography.Text type="secondary">{item.customer_name} · {item.customer_id}</Typography.Text></Space> },
+    { title: '套餐', minWidth: 180, render: (_, item) => <Space direction="vertical" size={0}><Typography.Text>{item.package_snapshot.name}</Typography.Text><Typography.Text type="secondary">{item.service_mode === 'at_store' ? '到店' : '上门'} · {item.distance_band}</Typography.Text></Space> },
+    { title: '最终报价', width: 140, render: (_, item) => item.quote_snapshot ? `${item.quote_snapshot.currency} ${item.quote_snapshot.total_amount}` : '-' },
+    { title: '状态', dataIndex: 'status', width: 130, render: (value: ServiceOrderStatus) => <Tag color={statusColors[value]}>{statusLabels[value]}</Tag> },
+    { title: '更新时间', dataIndex: 'updated_at', width: 160, responsive: ['lg'], render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
+    {
+      title: '操作',
+      width: 250,
+      fixed: 'right',
+      render: (_, item) => (
+        <Space wrap>
+          <Button size="small" icon={<Eye size={14} />} onClick={() => void showOrder(item)}>详情</Button>
+          <Button size="small" onClick={() => { addressForm.resetFields(); setAddressTarget(item); }}>查看地址</Button>
+          {item.status === 'disputed' ? <Button size="small" onClick={() => { orderReasonForm.resetFields(); setOrderAction({ order: item, action: 'resume' }); }}>恢复履约</Button> : null}
+          {item.status !== 'voided' ? <Button size="small" danger onClick={() => { orderReasonForm.resetFields(); setOrderAction({ order: item, action: 'void' }); }}>作废</Button> : null}
+        </Space>
+      ),
+    },
+  ];
+
+  const reviewColumns: ColumnsType<ServiceReview> = [
+    { title: '评价', dataIndex: 'review_id', width: 110, render: (value) => value.slice(0, 8) },
+    { title: '技师 / 顾客', minWidth: 210, render: (_, item) => <Space direction="vertical" size={0}><Typography.Text>{item.technician_name} · {item.technician_id}</Typography.Text><Typography.Text type="secondary">{item.customer_name} · {item.customer_id}</Typography.Text></Space> },
+    { title: '来源', dataIndex: 'source', width: 150, render: (value) => <Tag color={value === 'qr_verified' ? 'success' : 'blue'}>{value === 'qr_verified' ? '完成服务单评价' : '用户主动评价'}</Tag> },
+    { title: '评分', dataIndex: 'score', width: 90, render: (value) => value.toFixed(2) },
+    { title: '重复风险', width: 190, render: (_, item) => <Space direction="vertical" size={0}><Typography.Text>累计 {item.customer_review_count} 次</Typography.Text><Typography.Text type="secondary">30天 {item.customer_review_count_30d} / 90天 {item.customer_review_count_90d}</Typography.Text><Typography.Text type={item.similar_content_count > 1 ? 'danger' : 'secondary'}>相似内容 {item.similar_content_count} 次</Typography.Text></Space> },
+    { title: '文字', minWidth: 220, ellipsis: true, render: (_, item) => Object.values(item.text_answers || {}).join(';') || '-' },
+    { title: '状态', dataIndex: 'status', width: 100, render: (value) => <Tag>{value === 'pending' ? '待审核' : value === 'approved' ? '已批准' : value === 'rejected' ? '已拒绝' : '已作废'}</Tag> },
+    {
+      title: '操作',
+      width: 200,
+      fixed: 'right',
+      render: (_, item) => (
+        <Space>
+          {item.status === 'pending' ? <Button size="small" type="primary" onClick={() => approveReview(item)}>批准</Button> : null}
+          {item.status === 'pending' ? <Button size="small" danger onClick={() => { reviewReasonForm.resetFields(); setReviewAction({ review: item, action: 'reject' }); }}>拒绝</Button> : null}
+          {item.status === 'approved' ? <Button size="small" danger onClick={() => { reviewReasonForm.resetFields(); setReviewAction({ review: item, action: 'void' }); }}>作废</Button> : null}
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <PageHeader
+        title="技师服务"
+        actions={<Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void refresh()}>刷新</Button>}
+      />
+      <Alert
+        showIcon
+        type="info"
+        message="最小履约闭环"
+        description="仅统计平台服务单;不处理支付、退款或预约时间。主动评价与完成服务单评价审核通过后同等计分,因此公开榜单统一称为“审核评价排行”。"
+        style={{ marginBottom: 16 }}
+      />
+      <Surface>
+        <Tabs
+          items={[
+            {
+              key: 'overview',
+              label: '履约指标',
+              children: (
+                <>
+                  <Row gutter={[16, 16]}>
+                    <Col xs={12} lg={6}><Card><Statistic title="有效请求" value={metrics?.valid_requests || 0} /></Card></Col>
+                    <Col xs={12} lg={6}><Card><Statistic title="请求提交率" value={(metrics?.request_submission_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
+                    <Col xs={12} lg={6}><Card><Statistic title="报价率" value={(metrics?.quote_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
+                    <Col xs={12} lg={6}><Card><Statistic title="顾客确认率" value={(metrics?.customer_confirmation_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
+                    <Col xs={12} lg={6}><Card><Statistic title="撮合成功率" value={(metrics?.match_success_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
+                    <Col xs={12} lg={6}><Card><Statistic title="履约完成率" value={(metrics?.fulfillment_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
+                    <Col xs={12} lg={6}><Card><Statistic title="取消率" value={(metrics?.cancellation_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
+                    <Col xs={12} lg={6}><Card><Statistic title="取消方(顾客 / 技师)" value={`${metrics?.cancellation_distribution.customer || 0} / ${metrics?.cancellation_distribution.technician || 0}`} /></Card></Col>
+                  </Row>
+                  <Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>{metrics?.scope_note}</Typography.Paragraph>
+                  <Divider />
+                  <Typography.Title level={5}>顾客限额与二维码</Typography.Title>
+                  <Form form={settingsForm} layout="vertical" onFinish={(values) => void saveSettings(values)}>
+                    <Row gutter={16}>
+                      <Col xs={24} md={8}><Form.Item name="customer_max_open_orders" label="同时未关闭服务单" rules={[{ required: true }]}><InputNumber min={1} max={20} style={{ width: '100%' }} /></Form.Item></Col>
+                      <Col xs={24} md={8}><Form.Item name="customer_daily_request_limit" label="每日请求上限" rules={[{ required: true }]}><InputNumber min={1} max={100} style={{ width: '100%' }} /></Form.Item></Col>
+                      <Col xs={24} md={8}><Form.Item name="review_qr_expiry_hours" label="二维码有效小时" rules={[{ required: true }]}><InputNumber min={1} max={168} style={{ width: '100%' }} /></Form.Item></Col>
+                    </Row>
+                    <Button type="primary" htmlType="submit" disabled={!settings}>保存设置</Button>
+                  </Form>
+                </>
+              ),
+            },
+            {
+              key: 'templates',
+              label: `套餐模板 (${templates.length})`,
+              children: (
+                <>
+                  <div className="page-toolbar"><Button type="primary" icon={<Plus size={16} />} onClick={() => openTemplate()}>新建模板</Button></div>
+                  <Table rowKey="template_id" columns={templateColumns} dataSource={templates} loading={loading} pagination={false} scroll={{ x: 1050 }} />
+                </>
+              ),
+            },
+            {
+              key: 'orders',
+              label: `服务订单 (${orders.total})`,
+              children: (
+                <>
+                  <div className="page-toolbar">
+                    <Select allowClear value={orderStatus || undefined} placeholder="全部状态" style={{ width: 170 }} onChange={(value) => setOrderStatus(value || '')} options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))} />
+                    <Input allowClear value={query} onChange={(event) => setQuery(event.target.value)} onPressEnter={() => void loadOrders(1)} placeholder="订单、技师或顾客" style={{ width: 260 }} />
+                    <Button onClick={() => void loadOrders(1)}>查询</Button>
+                  </div>
+                  <Table rowKey="order_id" columns={orderColumns} dataSource={orders.items} loading={loading} pagination={{ current: orders.page, pageSize: orders.page_size, total: orders.total, showSizeChanger: false }} onChange={(pagination) => void loadOrders(pagination.current || 1)} scroll={{ x: 1200 }} />
+                </>
+              ),
+            },
+            {
+              key: 'reviews',
+              label: `评价审核 (${reviews.total})`,
+              children: (
+                <>
+                  <div className="page-toolbar">
+                    <Select value={reviewStatus} style={{ width: 140 }} onChange={setReviewStatus} options={[{ value: 'pending', label: '待审核' }, { value: 'approved', label: '已批准' }, { value: 'rejected', label: '已拒绝' }, { value: 'voided', label: '已作废' }, { value: '', label: '全部' }]} />
+                    <Input allowClear value={query} onChange={(event) => setQuery(event.target.value)} onPressEnter={() => void loadReviews(1)} placeholder="技师、顾客或评价 ID" style={{ width: 260 }} />
+                    <Button onClick={() => void loadReviews(1)}>查询</Button>
+                  </div>
+                  <Table rowKey="review_id" columns={reviewColumns} dataSource={reviews.items} loading={loading} pagination={{ current: reviews.page, pageSize: reviews.page_size, total: reviews.total, showSizeChanger: false }} onChange={(pagination) => void loadReviews(pagination.current || 1)} scroll={{ x: 1300 }} />
+                </>
+              ),
+            },
+            {
+              key: 'review-template',
+              label: `评价模板 V${reviewTemplate?.version || '-'}`,
+              children: (
+                <Form form={reviewTemplateForm} layout="vertical" onFinish={(values) => void publishReviewTemplate(values)}>
+                  <Alert showIcon type="warning" message="发布会创建新版本" description="新模板只影响之后提交的评价;历史评价保留原模板完整快照。评分权重必须合计 100%。" style={{ marginBottom: 16 }} />
+                  <Typography.Title level={5}>评分维度(1–5 个)</Typography.Title>
+                  <Form.List name="rating_questions">
+                    {(fields, { add, remove }) => (
+                      <>
+                        {fields.map((field) => (
+                          <Row gutter={12} key={field.key} align="middle">
+                            <Col xs={24} md={9}><Form.Item {...field} name={[field.name, 'label']} rules={[{ required: true, message: '请输入维度名称' }]}><Input placeholder="维度名称" /></Form.Item></Col>
+                            <Col xs={16} md={5}><Form.Item {...field} name={[field.name, 'weight']} rules={[{ required: true, message: '请输入权重' }]}><InputNumber min={1} max={100} addonAfter="%" style={{ width: '100%' }} /></Form.Item></Col>
+                            <Col xs={8} md={3}><Button danger type="text" icon={<Trash2 size={16} />} onClick={() => remove(field.name)} /></Col>
+                            <Form.Item {...field} name={[field.name, 'question_id']} hidden><Input /></Form.Item>
+                            <Form.Item {...field} name={[field.name, 'description']} hidden><Input /></Form.Item>
+                            <Form.Item {...field} name={[field.name, 'required']} hidden><Input /></Form.Item>
+                          </Row>
+                        ))}
+                        <Button icon={<Plus size={16} />} disabled={fields.length >= 5} onClick={() => add({ question_id: `rating_${Date.now()}`, required: true, weight: 10 })}>增加评分维度</Button>
+                      </>
+                    )}
+                  </Form.List>
+                  <Divider />
+                  <Typography.Title level={5}>文字问题(0–2 个)</Typography.Title>
+                  <Form.List name="text_questions">
+                    {(fields, { add, remove }) => (
+                      <>
+                        {fields.map((field) => (
+                          <Row gutter={12} key={field.key} align="middle">
+                            <Col xs={24} md={12}><Form.Item {...field} name={[field.name, 'label']} rules={[{ required: true, message: '请输入问题' }]}><Input placeholder="文字问题" /></Form.Item></Col>
+                            <Col xs={16} md={5}><Form.Item {...field} name={[field.name, 'max_length']}><InputNumber min={50} max={1000} style={{ width: '100%' }} /></Form.Item></Col>
+                            <Col xs={8} md={3}><Button danger type="text" icon={<Trash2 size={16} />} onClick={() => remove(field.name)} /></Col>
+                            <Form.Item {...field} name={[field.name, 'question_id']} hidden><Input /></Form.Item>
+                            <Form.Item {...field} name={[field.name, 'description']} hidden><Input /></Form.Item>
+                            <Form.Item {...field} name={[field.name, 'required']} hidden><Input /></Form.Item>
+                          </Row>
+                        ))}
+                        <Button icon={<Plus size={16} />} disabled={fields.length >= 2} onClick={() => add({ question_id: `text_${Date.now()}`, required: false, max_length: 500 })}>增加文字问题</Button>
+                      </>
+                    )}
+                  </Form.List>
+                  <Divider />
+                  <Button type="primary" htmlType="submit">发布新版评价模板</Button>
+                </Form>
+              ),
+            },
+            {
+              key: 'ranking-risk',
+              label: '排行与风控',
+              children: (
+                <>
+                  <div className="page-toolbar"><Button danger icon={<ShieldBan size={16} />} onClick={() => setBlockOpen(true)}>全局封禁顾客</Button></div>
+                  <Typography.Title level={5}>审核评价排行</Typography.Title>
+                  <Table rowKey={(item) => `${item.technician_id}:${item.category}`} dataSource={leaderboard.items} pagination={false} columns={[
+                    { title: '技师', render: (_, item) => `${item.display_name || item.technician_id}${item.username ? ` (@${item.username})` : ''}` },
+                    { title: '分类', dataIndex: 'category' },
+                    { title: '排行分', dataIndex: 'rank_score', render: (value) => value.toFixed(3) },
+                    { title: '平均分', dataIndex: 'average_score', render: (value) => value.toFixed(2) },
+                    { title: '批准评价', dataIndex: 'review_count' },
+                  ]} />
+                  <Divider />
+                  <Typography.Title level={5}>全局顾客黑名单</Typography.Title>
+                  <Table rowKey={(item) => `${item.scope}:${item.customer_id}`} dataSource={customerBlocks.items} pagination={false} columns={[
+                    { title: '顾客 ID', dataIndex: 'customer_id' },
+                    { title: '原因', dataIndex: 'reason' },
+                    { title: '更新时间', dataIndex: 'updated_at', render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
+                    { title: '操作', render: (_, item) => <Button size="small" onClick={() => unblockCustomer(item)}>解除封禁</Button> },
+                  ]} />
+                  <Divider />
+                  <Typography.Title level={5}>待处理举报</Typography.Title>
+                  <Table rowKey="report_id" dataSource={reports.items} pagination={false} columns={[
+                    { title: '举报', dataIndex: 'report_id', render: (value) => value.slice(0, 8) },
+                    { title: '举报人', dataIndex: 'reporter_id' },
+                    { title: '对象', render: (_, item) => `${item.target_type} · ${item.target_id.slice(0, 12)}` },
+                    { title: '原因', dataIndex: 'reason' },
+                    { title: '操作', render: (_, item) => <Space><Button size="small" type="primary" onClick={() => { reportForm.resetFields(); setReportTarget({ report: item, action: 'resolve' }); }}>处理完成</Button><Button size="small" onClick={() => { reportForm.resetFields(); setReportTarget({ report: item, action: 'dismiss' }); }}>驳回</Button></Space> },
+                  ]} />
+                  <Divider />
+                  <Typography.Title level={5}>完成评价二维码记录</Typography.Title>
+                  <Table rowKey="qr_id" dataSource={qrs.items} pagination={false} columns={[
+                    { title: '二维码', dataIndex: 'qr_id', render: (value) => value.slice(0, 8) },
+                    { title: '服务单', dataIndex: 'order_id', render: (value) => value.slice(0, 8) },
+                    { title: '技师 / 顾客', render: (_, item) => `${item.technician_id} / ${item.customer_id}` },
+                    { title: '状态', dataIndex: 'status' },
+                    { title: '过期时间', dataIndex: 'expires_at', render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
+                  ]} />
+                </>
+              ),
+            },
+          ]}
+        />
+      </Surface>
+
+      <Modal title={templateTarget ? '编辑套餐模板' : '新建套餐模板'} open={templateOpen} width={760} onCancel={() => { setTemplateOpen(false); templateForm.resetFields(); }} onOk={() => templateForm.submit()} okText="保存快照版本">
+        <Form form={templateForm} layout="vertical" onFinish={(values) => void saveTemplate(values)}>
+          <Row gutter={16}>
+            <Col xs={24} md={16}><Form.Item name="template_name" label="后台模板名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
+            <Col xs={24} md={8}><Form.Item name="sort_order" label="排序"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
+          </Row>
+          <Form.Item name="admin_note" label="后台备注"><Input /></Form.Item>
+          <Divider />
+          <Row gutter={16}>
+            <Col xs={24} md={12}><Form.Item name="name" label="默认套餐名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
+            <Col xs={24} md={12}><Form.Item name="category" label="服务分类" rules={[{ required: true }]}><Input /></Form.Item></Col>
+          </Row>
+          <Form.Item name="description" label="服务说明" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item>
+          <Form.Item name="tags" label="标签(逗号分隔)"><Input /></Form.Item>
+          <Form.Item name="service_modes" label="服务方式" rules={[{ required: true }]}><Select mode="multiple" options={[{ value: 'at_store', label: '到店' }, { value: 'onsite', label: '上门' }]} /></Form.Item>
+          <Row gutter={16}>
+            <Col xs={24} md={8}><Form.Item name="price_mode" label="价格模式" rules={[{ required: true }]}><Select options={Object.entries({ fixed: '固定价', starting_at: '起步价', range: '价格区间', negotiable: '面议' }).map(([value, label]) => ({ value, label }))} /></Form.Item></Col>
+            <Col xs={12} md={8}><Form.Item name="currency" label="ISO 币种" rules={[{ required: true }]}><Input maxLength={3} /></Form.Item></Col>
+            <Col xs={12} md={8}><Form.Item name="price_unit" label="计价单位" rules={[{ required: true }]}><Select options={[{ value: 'per_service', label: '每次' }, { value: 'per_hour', label: '每小时' }, { value: 'per_item', label: '每件' }, { value: 'per_visit', label: '每次上门' }]} /></Form.Item></Col>
+          </Row>
+          <Row gutter={16}>
+            <Col xs={12} md={6}><Form.Item name="min_price" label="最低/起步价"><Input /></Form.Item></Col>
+            <Col xs={12} md={6}><Form.Item name="max_price" label="最高价"><Input /></Form.Item></Col>
+            <Col xs={12} md={6}><Form.Item name="duration_minutes" label="默认时长"><InputNumber min={15} max={480} addonAfter="分" style={{ width: '100%' }} /></Form.Item></Col>
+            <Col xs={12} md={6}><Form.Item name="service_radius_km" label="上门半径"><InputNumber min={0.5} max={200} addonAfter="km" style={{ width: '100%' }} /></Form.Item></Col>
+          </Row>
+          <Form.Item name="included_items" label="包含项"><Input.TextArea rows={2} /></Form.Item>
+          <Form.Item name="excluded_items" label="不包含项"><Input.TextArea rows={2} /></Form.Item>
+          <Form.Item name="preparation" label="准备事项"><Input.TextArea rows={2} /></Form.Item>
+          <Form.Item name="out_of_range_policy" label="超区说明"><Input.TextArea rows={2} /></Form.Item>
+          <Row gutter={16}>
+            <Col xs={24} md={8}><Form.Item name="travel_fee_mode" label="交通费模式" rules={[{ required: true }]}><Select options={[{ value: 'included', label: '已包含' }, { value: 'fixed', label: '固定' }, { value: 'per_km', label: '按公里' }, { value: 'quoted', label: '最终报价确认' }]} /></Form.Item></Col>
+            <Col xs={12} md={8}><Form.Item name="travel_fee_amount" label="固定交通费"><Input /></Form.Item></Col>
+            <Col xs={12} md={8}><Form.Item name="travel_fee_per_km" label="每公里交通费"><Input /></Form.Item></Col>
+          </Row>
+          <Divider />
+          <Typography.Title level={5}>标准加项(最多 10 个)</Typography.Title>
+          <Form.List name="addons">
+            {(fields, { add, remove }) => (
+              <Space direction="vertical" style={{ width: '100%' }}>
+                {fields.map((field) => (
+                  <Row gutter={12} key={field.key} align="middle">
+                    <Col xs={24} md={7}><Form.Item {...field} name={[field.name, 'name']} label="加项名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
+                    <Col xs={8} md={4}><Form.Item {...field} name={[field.name, 'unit']} label="数量单位" rules={[{ required: true }]}><Input /></Form.Item></Col>
+                    <Col xs={8} md={5}><Form.Item {...field} name={[field.name, 'price']} label="单价" rules={[{ required: true }]}><Input /></Form.Item></Col>
+                    <Col xs={6} md={5}><Form.Item {...field} name={[field.name, 'extra_minutes']} label="额外时长"><InputNumber min={0} max={480} addonAfter="分" style={{ width: '100%' }} /></Form.Item></Col>
+                    <Col xs={2} md={3}><Button danger type="text" aria-label="删除加项" icon={<Trash2 size={16} />} onClick={() => remove(field.name)} /></Col>
+                    <Form.Item {...field} name={[field.name, 'addon_id']} hidden><Input /></Form.Item>
+                  </Row>
+                ))}
+                <Button icon={<Plus size={16} />} disabled={fields.length >= 10} onClick={() => add({ unit: '项', price: '0', extra_minutes: 0 })}>增加标准加项</Button>
+              </Space>
+            )}
+          </Form.List>
+        </Form>
+      </Modal>
+
+      <Drawer title={`服务单 ${detail?.order_id.slice(0, 8) || ''}`} open={Boolean(detail)} width={680} onClose={() => setDetail(null)}>
+        {detail ? (
+          <>
+            <Descriptions column={1} bordered size="small" items={[
+              { key: 'status', label: '状态', children: statusLabels[detail.status] },
+              { key: 'people', label: '技师 / 顾客', children: `${detail.technician_name} (${detail.technician_id}) / ${detail.customer_name} (${detail.customer_id})` },
+              { key: 'package', label: '套餐', children: `${detail.package_snapshot.name} · ${detail.category}` },
+              { key: 'mode', label: '方式 / 距离', children: `${detail.service_mode === 'at_store' ? '到店' : '上门'} · ${detail.distance_band}` },
+              { key: 'requirements', label: '需求', children: detail.requirements },
+              { key: 'quote', label: '最终报价', children: detail.quote_snapshot ? `${detail.quote_snapshot.currency} ${detail.quote_snapshot.total_amount}` : '-' },
+              { key: 'reason', label: '关闭/争议原因', children: detail.close_reason || detail.dispute_reason || '-' },
+            ]} />
+            <Divider />
+            <Typography.Title level={5}>状态轨迹</Typography.Title>
+            {(detail.events || []).map((event, index) => (
+              <Typography.Paragraph key={String(event.event_id || index)}>
+                {dayjs(String(event.created_at)).format('YYYY-MM-DD HH:mm:ss')} · {String(event.event_type)} · {String(event.reason || '')}
+              </Typography.Paragraph>
+            ))}
+          </>
+        ) : null}
+      </Drawer>
+
+      <Modal title={orderAction?.action === 'void' ? '作废服务单' : '恢复争议服务单'} open={Boolean(orderAction)} onCancel={() => setOrderAction(null)} onOk={() => orderReasonForm.submit()} okText="确认执行" okButtonProps={{ danger: orderAction?.action === 'void' }}>
+        <Form form={orderReasonForm} layout="vertical" onFinish={(values) => void submitOrderAction(values)}><Form.Item name="reason" label="处理原因" rules={[{ required: true, message: '必须填写原因' }]}><Input.TextArea rows={4} /></Form.Item></Form>
+      </Modal>
+      <Modal title="查看精确地址" open={Boolean(addressTarget)} onCancel={() => setAddressTarget(null)} onOk={() => addressForm.submit()} okText="确认查看" okButtonProps={{ danger: true }}>
+        <Alert showIcon type="error" message="精确地址属于敏感信息" description="必须填写真实争议处理原因。本次访问会写入服务单事件和管理员审计。" style={{ marginBottom: 16 }} />
+        <Form form={addressForm} layout="vertical" onFinish={(values) => void revealAddress(values)}><Form.Item name="reason" label="查看原因" rules={[{ required: true, message: '必须填写查看原因' }]}><Input.TextArea rows={4} /></Form.Item></Form>
+      </Modal>
+      <Modal title={reviewAction?.action === 'void' ? '作废已批准评价' : '拒绝评价'} open={Boolean(reviewAction)} onCancel={() => setReviewAction(null)} onOk={() => reviewReasonForm.submit()} okText="确认执行" okButtonProps={{ danger: true }}>
+        <Form form={reviewReasonForm} layout="vertical" onFinish={(values) => void submitReviewAction(values)}><Form.Item name="reason" label="原因" rules={[{ required: true, message: '必须填写原因' }]}><Input.TextArea rows={4} /></Form.Item></Form>
+      </Modal>
+      <Modal title="全局封禁顾客" open={blockOpen} onCancel={() => setBlockOpen(false)} onOk={() => blockForm.submit()} okText="确认封禁" okButtonProps={{ danger: true }}>
+        <Form form={blockForm} layout="vertical" onFinish={(values) => void blockCustomer(values)}>
+          <Form.Item name="customer_id" label="Telegram 用户 ID" rules={[{ required: true }]}><Input /></Form.Item>
+          <Form.Item name="reason" label="封禁原因" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
+        </Form>
+      </Modal>
+      <Modal title={reportTarget?.action === 'resolve' ? '标记举报已处理' : '驳回举报'} open={Boolean(reportTarget)} onCancel={() => setReportTarget(null)} onOk={() => reportForm.submit()} okText="确认处理">
+        <Form form={reportForm} layout="vertical" onFinish={(values) => void submitReportAction(values)}>
+          <Form.Item name="reason" label="处理说明" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
+        </Form>
+      </Modal>
+    </>
+  );
+}

+ 219 - 0
admin-web/src/types.ts

@@ -267,6 +267,12 @@ export interface DirectoryProfile {
     source?: string;
     updated_at?: string;
   } | null;
+  service_profile?: {
+    is_complete: boolean;
+    accepting_requests: boolean;
+    service_modes: ServiceMode[];
+    packages: ServicePackage[];
+  } | null;
 }
 
 export interface DirectoryLocation {
@@ -298,3 +304,216 @@ export interface DirectorySettings {
   nearby_radius_km: number;
   nearby_radius_options_km: number[];
 }
+
+export type ServiceMode = 'at_store' | 'onsite';
+export type ServiceOrderStatus =
+  | 'requested'
+  | 'quoted'
+  | 'confirmed'
+  | 'in_progress'
+  | 'completion_pending'
+  | 'completed'
+  | 'rejected'
+  | 'canceled_customer'
+  | 'canceled_technician'
+  | 'disputed'
+  | 'voided';
+
+export interface ServiceAddon {
+  addon_id?: string;
+  name: string;
+  unit: string;
+  price: string;
+  extra_minutes: number;
+}
+
+export interface ServicePackage {
+  package_id: string;
+  name: string;
+  category: string;
+  description: string;
+  tags: string[];
+  service_modes: ServiceMode[];
+  price_mode: 'fixed' | 'starting_at' | 'range' | 'negotiable';
+  currency: string;
+  price_unit: 'per_service' | 'per_hour' | 'per_item' | 'per_visit';
+  min_price: string;
+  max_price: string;
+  price_display?: string;
+  duration_minutes?: number | null;
+  included_items?: string;
+  excluded_items?: string;
+  preparation?: string;
+  addons: ServiceAddon[];
+  travel_fee: {
+    mode: 'included' | 'fixed' | 'per_km' | 'quoted';
+    amount?: string;
+    per_km?: string;
+    description?: string;
+  };
+  service_radius_km?: number | null;
+  out_of_range_policy?: string;
+  source_type?: 'template' | 'custom';
+  source_template_id?: string | null;
+  source_template_version?: number | null;
+  customized_from_template?: boolean;
+}
+
+export interface ServicePackageTemplate {
+  template_id: string;
+  version: number;
+  name: string;
+  admin_note?: string;
+  package: ServicePackage;
+  status: 'enabled' | 'disabled' | 'archived';
+  sort_order: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface ServiceQuote {
+  quote_id: string;
+  order_id: string;
+  version: number;
+  currency: string;
+  base_amount: string;
+  travel_fee: string;
+  addons: Array<{ name: string; quantity: number; unit_price: string }>;
+  discount: string;
+  total_amount: string;
+  note?: string;
+  created_at: string;
+}
+
+export interface ServiceOrder {
+  order_id: string;
+  customer_id: string;
+  customer_name: string;
+  technician_id: string;
+  technician_name: string;
+  package_snapshot: ServicePackage;
+  category: string;
+  service_mode: ServiceMode;
+  requirements: string;
+  distance_band: string;
+  status: ServiceOrderStatus;
+  version: number;
+  quote_snapshot?: ServiceQuote;
+  current_quote_id?: string;
+  close_reason?: string;
+  dispute_reason?: string;
+  created_at: string;
+  updated_at: string;
+  closed_at?: string;
+  quotes?: ServiceQuote[];
+  events?: Array<Record<string, unknown>>;
+  qr_records?: ServiceReviewQr[];
+}
+
+export interface ReviewQuestion {
+  question_id: string;
+  label: string;
+  description?: string;
+  required: boolean;
+  weight?: number;
+  max_length?: number;
+}
+
+export interface ServiceReviewTemplate {
+  template_id: string;
+  version: number;
+  status: 'active' | 'retired';
+  rating_questions: ReviewQuestion[];
+  text_questions: ReviewQuestion[];
+  published_at: string;
+}
+
+export interface ServiceReview {
+  review_id: string;
+  technician_id: string;
+  technician_name: string;
+  customer_id: string;
+  customer_name: string;
+  source: 'qr_verified' | 'student_initiated';
+  category: string;
+  package_snapshot: ServicePackage;
+  score: number;
+  anonymous: boolean;
+  status: 'pending' | 'approved' | 'rejected' | 'voided';
+  text_answers: Record<string, string>;
+  customer_review_count: number;
+  customer_review_count_30d: number;
+  customer_review_count_90d: number;
+  similar_content_count: number;
+  created_at: string;
+  moderated_at?: string;
+}
+
+export interface ServiceReviewQr {
+  qr_id: string;
+  order_id: string;
+  technician_id: string;
+  customer_id: string;
+  status: 'issued' | 'claimed' | 'expired' | 'revoked' | 'voided';
+  expires_at: string;
+  created_at: string;
+  claimed_at?: string;
+}
+
+export interface ServiceLeaderboardItem {
+  technician_id: string;
+  display_name?: string;
+  username?: string;
+  category: string;
+  review_count: number;
+  average_score: number;
+  category_average: number;
+  rank_score: number;
+  last_review_at: string;
+}
+
+export interface ServiceCustomerBlock {
+  scope: 'global' | 'technician';
+  technician_id: string;
+  customer_id: string;
+  active: boolean;
+  reason?: string;
+  actor_id?: string;
+  updated_at: string;
+}
+
+export interface ServiceReport {
+  report_id: string;
+  reporter_id: string;
+  target_type: 'technician' | 'customer' | 'order' | 'review';
+  target_id: string;
+  reason: string;
+  status: 'open' | 'resolved' | 'dismissed';
+  created_at: string;
+  handled_at?: string;
+  handling_reason?: string;
+}
+
+export interface ServiceSettings {
+  customer_max_open_orders: number;
+  customer_daily_request_limit: number;
+  review_qr_expiry_hours: number;
+}
+
+export interface FulfillmentMetrics {
+  counts: Record<ServiceOrderStatus, number>;
+  valid_requests: number;
+  quoted_requests: number;
+  confirmed_requests: number;
+  directory_views: number;
+  technician_views: number;
+  request_starts: number;
+  request_submission_rate: number;
+  quote_rate: number;
+  customer_confirmation_rate: number;
+  match_success_rate: number;
+  fulfillment_rate: number;
+  cancellation_rate: number;
+  cancellation_distribution: { customer: number; technician: number };
+  scope_note: string;
+}

+ 51 - 3
admin-web/tests/e2e/admin.spec.ts

@@ -184,7 +184,7 @@ test.beforeEach(async ({ page }) => {
         items: [{
           user_id: '20001',
           username: 'teacher_demo',
-          display_name: '示例老师',
+          display_name: '示例技师',
           application_status: 'pending',
           listed: false,
           online: false,
@@ -210,6 +210,49 @@ test.beforeEach(async ({ page }) => {
         nearby_radius_km: 20,
         nearby_radius_options_km: [5, 10, 20, 50],
       };
+    } else if (path.endsWith('/directory/fulfillment-metrics')) {
+      data = {
+        counts: {},
+        valid_requests: 3,
+        quoted_requests: 2,
+        confirmed_requests: 1,
+        request_submission_rate: 1,
+        quote_rate: 0.6667,
+        customer_confirmation_rate: 0.5,
+        match_success_rate: 0.3333,
+        fulfillment_rate: 1,
+        cancellation_rate: 0,
+        cancellation_distribution: { customer: 0, technician: 0 },
+        scope_note: '仅统计平台服务单;直接 Telegram 私聊成交不在统计范围。',
+      };
+    } else if (path.endsWith('/directory/service-settings')) {
+      data = {
+        customer_max_open_orders: 3,
+        customer_daily_request_limit: 10,
+        review_qr_expiry_hours: 24,
+      };
+    } else if (path.endsWith('/directory/package-templates')) {
+      data = { items: [], total: 0 };
+    } else if (
+      path.endsWith('/directory/service-orders') ||
+      path.endsWith('/directory/reviews') ||
+      path.endsWith('/directory/review-qrs') ||
+      path.endsWith('/directory/leaderboard') ||
+      path.endsWith('/directory/customer-blocks') ||
+      path.endsWith('/directory/reports')
+    ) {
+      data = { items: [], total: 0, page: 1, page_size: 20 };
+    } else if (path.endsWith('/directory/review-template')) {
+      data = {
+        template_id: 'review1',
+        version: 1,
+        status: 'active',
+        published_at: '2026-07-26T00:00:00Z',
+        rating_questions: [
+          { question_id: 'effect', label: '服务效果', required: true, weight: 100 },
+        ],
+        text_questions: [],
+      };
     } else if (path.endsWith('/settings')) {
       data = {
         web_address: 'http://127.0.0.1:8088/admin',
@@ -258,7 +301,8 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
 
   for (const [path, heading] of [
     ['/admin/points', '积分管理'],
-    ['/admin/directory', '老师与附近'],
+    ['/admin/directory', '技师与附近'],
+    ['/admin/services', '技师服务'],
     ['/admin/audit', '操作日志'],
     ['/admin/settings', '系统设置'],
   ] as const) {
@@ -269,10 +313,14 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
       await page.screenshot({ path: testInfo.outputPath('settings.png'), fullPage: true });
     }
     if (path === '/admin/directory') {
-      await expect(page.getByText('示例老师')).toBeVisible();
+      await expect(page.getByText('示例技师')).toBeVisible();
       await expect(page.getByText('位置属于敏感信息')).toBeVisible();
       await page.screenshot({ path: testInfo.outputPath('directory.png'), fullPage: true });
     }
+    if (path === '/admin/services') {
+      await expect(page.getByText('最小履约闭环')).toBeVisible();
+      await page.screenshot({ path: testInfo.outputPath('services.png'), fullPage: true });
+    }
     expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
   }
 });

+ 4 - 4
admin-web/tests/unit/Directory.test.tsx

@@ -17,7 +17,7 @@ beforeEach(() => {
           {
             user_id: '100',
             username: 'teacher100',
-            display_name: '王老师',
+            display_name: '王技师',
             application_status: 'pending',
             listed: false,
             online: false,
@@ -52,12 +52,12 @@ beforeEach(() => {
   });
 });
 
-test('展示老师申请、定位来源并且不显示区域解析', async () => {
+test('展示技师申请、定位来源并且不显示区域解析', async () => {
   render(<DirectoryPage />);
 
-  expect(screen.getByText('老师与附近')).toBeInTheDocument();
+  expect(screen.getByText('技师与附近')).toBeInTheDocument();
   expect(screen.getByText('位置属于敏感信息')).toBeInTheDocument();
-  expect(await screen.findByText('王老师')).toBeInTheDocument();
+  expect(await screen.findByText('王技师')).toBeInTheDocument();
   expect(screen.getByText('@teacher100 · 100')).toBeInTheDocument();
   expect(screen.getByText('Telegram 定位')).toBeInTheDocument();
   expect(screen.queryByText('上海市 黄浦区')).not.toBeInTheDocument();

+ 106 - 0
admin-web/tests/unit/Services.test.tsx

@@ -0,0 +1,106 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, expect, test, vi } from 'vitest';
+
+import ServicesPage from '@/pages/Services';
+import { apiRequest } from '@/services/api';
+
+vi.mock('@/services/api', () => ({
+  apiRequest: vi.fn(),
+  jsonOptions: vi.fn((method, body) => ({ method, body: JSON.stringify(body) })),
+}));
+
+const emptyPage = { items: [], total: 0, page: 1, page_size: 20 };
+
+beforeEach(() => {
+  vi.mocked(apiRequest).mockImplementation(async (path) => {
+    if (path === '/directory/fulfillment-metrics') {
+      return {
+        counts: {},
+        valid_requests: 12,
+        quoted_requests: 9,
+        confirmed_requests: 6,
+        request_submission_rate: 1,
+        quote_rate: 0.75,
+        customer_confirmation_rate: 0.6667,
+        match_success_rate: 0.5,
+        fulfillment_rate: 0.8333,
+        cancellation_rate: 0.1,
+        cancellation_distribution: { customer: 1, technician: 0 },
+        scope_note: '仅统计平台服务单;直接 Telegram 私聊成交不在统计范围。',
+      };
+    }
+    if (path === '/directory/service-settings') {
+      return {
+        customer_max_open_orders: 3,
+        customer_daily_request_limit: 10,
+        review_qr_expiry_hours: 24,
+      };
+    }
+    if (path.startsWith('/directory/package-templates')) {
+      return {
+        items: [
+          {
+            template_id: 'tpl1',
+            version: 2,
+            name: '基础护理模板',
+            status: 'enabled',
+            sort_order: 1,
+            created_at: '2026-07-26T00:00:00Z',
+            updated_at: '2026-07-26T00:00:00Z',
+            package: {
+              package_id: 'pkg1',
+              name: '基础护理',
+              category: '护理',
+              description: '标准服务',
+              tags: ['护理'],
+              service_modes: ['at_store'],
+              price_mode: 'fixed',
+              currency: 'CNY',
+              price_unit: 'per_service',
+              min_price: '100.00',
+              max_price: '100.00',
+              addons: [],
+              travel_fee: { mode: 'quoted' },
+            },
+          },
+        ],
+        total: 1,
+      };
+    }
+    if (path.startsWith('/directory/service-orders')) return emptyPage;
+    if (path.startsWith('/directory/reviews')) return emptyPage;
+    if (path.startsWith('/directory/review-qrs')) return emptyPage;
+    if (path.startsWith('/directory/leaderboard')) return emptyPage;
+    if (path.startsWith('/directory/customer-blocks')) return emptyPage;
+    if (path.startsWith('/directory/reports')) return emptyPage;
+    if (path === '/directory/review-template') {
+      return {
+        template_id: 'review1',
+        version: 1,
+        status: 'active',
+        published_at: '2026-07-26T00:00:00Z',
+        rating_questions: [
+          { question_id: 'effect', label: '服务效果', required: true, weight: 100 },
+        ],
+        text_questions: [],
+      };
+    }
+    throw new Error(`Unexpected request: ${path}`);
+  });
+});
+
+test('展示履约漏斗、统计边界和套餐模板', async () => {
+  render(<ServicesPage />);
+
+  expect(screen.getByText('技师服务')).toBeInTheDocument();
+  expect(screen.getByText('最小履约闭环')).toBeInTheDocument();
+  expect(await screen.findByText('12')).toBeInTheDocument();
+  expect(screen.getByText('仅统计平台服务单;直接 Telegram 私聊成交不在统计范围。')).toBeInTheDocument();
+
+  fireEvent.click(screen.getByRole('tab', { name: /套餐模板/ }));
+  expect(await screen.findByText('基础护理模板')).toBeInTheDocument();
+  expect(screen.getByText('护理 · V2')).toBeInTheDocument();
+  fireEvent.click(screen.getByRole('button', { name: '新建模板' }));
+  expect(await screen.findByText('标准加项(最多 10 个)')).toBeInTheDocument();
+  expect(screen.getByRole('button', { name: '增加标准加项' })).toBeInTheDocument();
+});

+ 8 - 0
config.env.template

@@ -35,6 +35,14 @@ ADMIN_WEB_SESSION_HOURS=12
 ADMIN_WEB_COOKIE_SECURE=0
 ADMIN_WEB_UPLOAD_MAX_MB=20
 ADMIN_WEB_STATIC_DIR=admin-web/dist
+
+# Required before storing order-bound exact addresses. Generate with:
+# uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+SERVICE_ADDRESS_ENCRYPTION_KEY=
+SERVICE_ADDRESS_RETENTION_DAYS=7
+SERVICE_REVIEW_QR_EXPIRY_HOURS=24
+SERVICE_CUSTOMER_MAX_OPEN_ORDERS=3
+SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT=10
 BOT_PROFILES_PATH=runtime/bot_profiles.json
 
 # Optional Nominatim contact is configured in the admin panel.

+ 4 - 0
pyproject.toml

@@ -15,6 +15,8 @@ dependencies = [
     "motor~=3.6",
     "pymongo~=4.15",
     "pillow~=12.3.0",
+    "cryptography~=45.0",
+    "qrcode~=8.2",
     "psutil~=5.9.8",
     "pykeyboard~=0.1.5",
     "python-arq~=6.0.7",
@@ -64,10 +66,12 @@ include = [
     "wbb/modules/karma.py",
     "wbb/modules/points.py",
     "wbb/modules/teacher_directory.py",
+    "wbb/modules/technician_services.py",
     "wbb/utils/dbadmin.py",
     "wbb/utils/dbdirectory.py",
     "wbb/utils/dbgiveaway.py",
     "wbb/utils/dbpoints.py",
+    "wbb/utils/dbservice.py",
     "wbb/web_admin.py",
 ]
 

+ 9 - 0
sample_config.py

@@ -54,6 +54,15 @@ ADMIN_WEB_COOKIE_SECURE = os.environ.get(
 ).lower() in ["true", "1"]
 ADMIN_WEB_UPLOAD_MAX_MB = _env_int("ADMIN_WEB_UPLOAD_MAX_MB", 20)
 ADMIN_WEB_STATIC_DIR = os.environ.get("ADMIN_WEB_STATIC_DIR", "admin-web/dist")
+SERVICE_ADDRESS_ENCRYPTION_KEY = os.environ.get(
+    "SERVICE_ADDRESS_ENCRYPTION_KEY", ""
+)
+SERVICE_ADDRESS_RETENTION_DAYS = _env_int("SERVICE_ADDRESS_RETENTION_DAYS", 7)
+SERVICE_REVIEW_QR_EXPIRY_HOURS = _env_int("SERVICE_REVIEW_QR_EXPIRY_HOURS", 24)
+SERVICE_CUSTOMER_MAX_OPEN_ORDERS = _env_int("SERVICE_CUSTOMER_MAX_OPEN_ORDERS", 3)
+SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT = _env_int(
+    "SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT", 10
+)
 
 USERBOT_ENABLED = os.environ.get("USERBOT_ENABLED", "False").lower() in ["true", "1"]
 ADMIN_BOOTSTRAP_MODE = os.environ.get("WBB_ADMIN_BOOTSTRAP", "0").lower() in ["true", "1"]

+ 12 - 0
tests/conftest.py

@@ -17,6 +17,7 @@ class FakeTelegramApp:
     def __init__(self) -> None:
         self.members: dict[tuple[int, int], object] = {}
         self.sent_messages: list[tuple[int, str]] = []
+        self.sent_photos: list[tuple[int, object, str]] = []
 
     async def get_chat_member(self, chat_id: int, user_id: int):
         member = self.members.get((int(chat_id), int(user_id)))
@@ -41,6 +42,10 @@ class FakeTelegramApp:
         self.sent_messages.append((int(chat_id), text))
         return SimpleNamespace(chat=SimpleNamespace(id=int(chat_id)), id=len(self.sent_messages))
 
+    async def send_photo(self, chat_id: int, photo: object, caption: str = "", **_kwargs):
+        self.sent_photos.append((int(chat_id), photo, caption))
+        return SimpleNamespace(chat=SimpleNamespace(id=int(chat_id)), id=len(self.sent_photos))
+
     def on_message(self, *_args, **_kwargs):
         return lambda function: function
 
@@ -89,6 +94,13 @@ def app_modules(tmp_path):
     fake_wbb.ADMIN_WEB_UPLOAD_MAX_MB = 20
     fake_wbb.ADMIN_WEB_HOST = "127.0.0.1"
     fake_wbb.ADMIN_WEB_PORT = 8088
+    fake_wbb.SERVICE_ADDRESS_ENCRYPTION_KEY = (
+        "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
+    )
+    fake_wbb.SERVICE_ADDRESS_RETENTION_DAYS = 7
+    fake_wbb.SERVICE_REVIEW_QR_EXPIRY_HOURS = 24
+    fake_wbb.SERVICE_CUSTOMER_MAX_OPEN_ORDERS = 3
+    fake_wbb.SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT = 10
     fake_wbb.BOT_PROFILES_PATH = str(tmp_path / "bot_profiles.json")
     sys.modules["wbb"] = fake_wbb
 

+ 12 - 12
tests/test_directory.py

@@ -14,7 +14,7 @@ async def _approved_teacher(directory_db, *, user_id: int = 200):
         user_id=user_id,
         username=f"teacher{user_id}",
         first_name="王",
-        last_name="老师",
+        last_name="技师",
     )
     await directory_db.save_directory_location(
         user_id=user_id,
@@ -22,7 +22,7 @@ async def _approved_teacher(directory_db, *, user_id: int = 200):
         latitude=39.9042,
         source="test",
         actor_id=user_id,
-        actor_name="王老师",
+        actor_name="王技师",
     )
     _, created = await directory_db.submit_teacher_application(
         user_id=user_id,
@@ -43,7 +43,7 @@ async def _approved_teacher(directory_db, *, user_id: int = 200):
         user_id=user_id,
         action="list",
         actor_id=user_id,
-        actor_name="王老师",
+        actor_name="王技师",
         source="test",
     )
     return await directory_db.get_directory_profile(user_id)
@@ -61,7 +61,7 @@ async def test_teacher_application_distance_listing_and_presence(app_modules):
         user_id=200,
         action="online",
         actor_id=200,
-        actor_name="王老师",
+        actor_name="王技师",
         source="test",
         idempotency_key="presence:one",
     )
@@ -72,7 +72,7 @@ async def test_teacher_application_distance_listing_and_presence(app_modules):
         user_id=200,
         action="online",
         actor_id=200,
-        actor_name="王老师",
+        actor_name="王技师",
         source="test",
         idempotency_key="presence:one",
     )
@@ -87,7 +87,7 @@ async def test_teacher_application_distance_listing_and_presence(app_modules):
     )
     assert total == 1
     public = directory_service.public_teacher(items[0])
-    assert public["display_name"] == "王 老师"
+    assert public["display_name"] == "王 技师"
     assert 800 < public["distance_meters"] < 1000
     assert "longitude" not in public
     assert "latitude" not in public
@@ -294,12 +294,12 @@ async def test_role_specific_bottom_menus_are_chinese(app_modules):
         ]
         assert "Telegram 定位" in student_labels
         assert "手动选择位置" in student_labels
-        assert "附近老师" in student_labels
-        assert "申请成为老师" in student_labels
+        assert "附近技师" in student_labels
+        assert "申请成为技师" in student_labels
         assert "我的状态" not in student_labels
         assert "上线" not in student_labels
         assert "下线" not in student_labels
-        assert "老师审批" not in student_labels
+        assert "技师审批" not in student_labels
 
         teacher_admin = module._directory_keyboard(
             {
@@ -314,7 +314,7 @@ async def test_role_specific_bottom_menus_are_chinese(app_modules):
         ]
         assert "下榜" in teacher_labels
         assert "🔴 下线" in teacher_labels
-        assert "老师审批" in teacher_labels
+        assert "技师审批" in teacher_labels
 
         group_student = module._group_directory_keyboard(
             {"application_status": "none"},
@@ -324,7 +324,7 @@ async def test_role_specific_bottom_menus_are_chinese(app_modules):
         group_student_labels = [
             button.text for row in group_student.inline_keyboard for button in row
         ]
-        assert "申请成为老师" in group_student_labels
+        assert "申请成为技师" in group_student_labels
         assert "我的状态" not in group_student_labels
 
         teacher_rows = module._teacher_result_markup(
@@ -332,7 +332,7 @@ async def test_role_specific_bottom_menus_are_chinese(app_modules):
                 {
                     "user_id": index,
                     "username": f"teacher{index}",
-                    "display_name": f"老师 {index}",
+                    "display_name": f"技师 {index}",
                     "online": index % 2 == 0,
                 }
                 for index in range(1, 5)

+ 578 - 0
tests/test_service_fulfillment.py

@@ -0,0 +1,578 @@
+from __future__ import annotations
+
+import asyncio
+from datetime import timedelta
+from types import SimpleNamespace
+
+import pytest
+from aiohttp import CookieJar
+from aiohttp.test_utils import TestClient, TestServer
+
+
+def _user(user_id: int, *, username: str | None = None, name: str = "测试用户"):
+    return SimpleNamespace(
+        id=user_id,
+        username=username,
+        first_name=name,
+        last_name=None,
+        is_bot=False,
+    )
+
+
+def _package(*, name: str = "基础护理", category: str = "护理"):
+    return {
+        "name": name,
+        "category": category,
+        "description": "标准线下服务套餐",
+        "tags": ["线下", "护理"],
+        "service_modes": ["at_store", "onsite"],
+        "price_mode": "fixed",
+        "currency": "CNY",
+        "price_unit": "per_service",
+        "min_price": "100",
+        "max_price": "100",
+        "duration_minutes": 60,
+        "included_items": "基础服务",
+        "excluded_items": "额外耗材",
+        "addons": [
+            {
+                "name": "延长服务",
+                "unit": "次",
+                "price": "20",
+                "extra_minutes": 15,
+            }
+        ],
+        "travel_fee": {"mode": "quoted"},
+        "service_radius_km": 20,
+        "out_of_range_policy": "超区请重新询价",
+        "preparation": "请提前沟通需求",
+    }
+
+
+async def _technician(app_modules, service_db, *, user_id: int = 200, category: str = "护理"):
+    directory_db = app_modules.load("wbb.utils.dbdirectory")
+    await directory_db.upsert_directory_identity(
+        user_id=user_id,
+        username=f"technician{user_id}",
+        first_name="认证",
+        last_name="技师",
+    )
+    await directory_db.save_directory_location(
+        user_id=user_id,
+        longitude=116.4074,
+        latitude=39.9042,
+        source="test",
+        actor_id=user_id,
+        actor_name="认证技师",
+    )
+    await directory_db.upsert_directory_membership(
+        bot_id="primary",
+        chat_id=-100200,
+        user_id=user_id,
+        status="member",
+        active=True,
+        verified=True,
+    )
+    await directory_db.submit_teacher_application(
+        user_id=user_id,
+        source="test",
+        bot_id="primary",
+        chat_id=-100200,
+    )
+    await directory_db.decide_teacher_application(
+        user_id=user_id,
+        action="approve",
+        actor_id="admin",
+        actor_name="admin",
+        source="test",
+    )
+    await directory_db.set_teacher_state(
+        user_id=user_id,
+        action="list",
+        actor_id=user_id,
+        actor_name="认证技师",
+        source="test",
+    )
+    profile = await service_db.publish_technician_profile(
+        user_id,
+        {
+            "headline": "专注线下护理",
+            "bio": "提供到店和上门服务",
+            "tags": ["护理"],
+            "contact_hours": "10:00-20:00",
+            "public_area_text": "北京朝阳区附近",
+            "venue": {"name": "护理工作室", "address_hint": "测试路 100 号"},
+            "onsite_policy": {"description": "20 公里内可上门"},
+            "packages": [_package(category=category)],
+        },
+        actor_id=user_id,
+    )
+    return profile
+
+
+async def _accepted_customer(service_db, *, user_id: int = 300):
+    customer = _user(user_id, username=None, name=f"顾客{user_id}")
+    await service_db.observe_customer(customer, accepted_terms=True)
+    return customer
+
+
+async def _order(service_db, customer, profile, *, service_mode: str = "onsite"):
+    package = profile["service_profile"]["packages"][0]
+    kwargs = {
+        "customer": customer,
+        "technician_id": profile["user_id"],
+        "package_id": package["package_id"],
+        "service_mode": service_mode,
+        "requirements": "希望完成一次标准护理服务",
+    }
+    if service_mode == "onsite":
+        kwargs.update(
+            {
+                "longitude": 116.4174,
+                "latitude": 39.9042,
+                "address_text": "测试街道 1 号 101 室",
+            }
+        )
+    else:
+        kwargs.update({"longitude": 116.4174, "latitude": 39.9042})
+    return await service_db.create_service_order(**kwargs)
+
+
+async def test_public_customer_order_quote_address_and_completion_qr(app_modules):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    profile = await _technician(app_modules, service_db)
+    customer = await _accepted_customer(service_db)
+
+    order = await _order(service_db, customer, profile)
+    assert order["status"] == "requested"
+    assert order["distance_band"] == "1 公里内"
+    stored_address = await app_modules.wbb.control_db.service_order_addresses.find_one(
+        {"order_id": order["order_id"]}
+    )
+    assert stored_address["redact_after"] is None
+    assert "测试街道" not in stored_address["encrypted_payload"]
+
+    hidden = await service_db.get_order_for_actor(
+        order["order_id"], customer.id, reveal_address=True
+    )
+    assert "exact_address" not in hidden
+    quoted_order, quote = await service_db.submit_service_quote(
+        order["order_id"],
+        profile["user_id"],
+        {
+            "base_amount": "100",
+            "travel_fee": "10",
+            "addons": [{"name": "延长服务", "quantity": 2, "unit_price": "20"}],
+            "discount": "5",
+            "currency": "CNY",
+            "total_amount": "145",
+            "note": "最终报价",
+        },
+    )
+    assert quoted_order["status"] == "quoted"
+    assert quote["total_amount"] == "145.00"
+
+    confirmed = await service_db.confirm_service_quote(order["order_id"], customer.id)
+    assert confirmed["status"] == "confirmed"
+    revealed = await service_db.get_order_for_actor(
+        order["order_id"], profile["user_id"], reveal_address=True
+    )
+    assert revealed["exact_address"]["address_text"] == "测试街道 1 号 101 室"
+
+    started = await service_db.start_service_order(order["order_id"], profile["user_id"])
+    assert started["status"] == "in_progress"
+    qr, token = await service_db.issue_review_qr(order["order_id"], profile["user_id"])
+    assert qr["status"] == "issued"
+    assert len(token) >= 22
+    assert token not in str(qr)
+    with pytest.raises(service_db.ServiceDataError, match="不属于当前顾客"):
+        await service_db.preview_review_qr(token, customer.id + 1)
+
+    claim_results = await asyncio.gather(
+        service_db.claim_review_qr(token, customer.id),
+        service_db.claim_review_qr(token, customer.id),
+        return_exceptions=True,
+    )
+    successful_claims = [item for item in claim_results if isinstance(item, tuple)]
+    failed_claims = [item for item in claim_results if isinstance(item, Exception)]
+    assert len(successful_claims) == 1
+    assert len(failed_claims) == 1
+    claimed, completed = successful_claims[0]
+    assert claimed["status"] == "claimed"
+    assert completed["status"] == "completed"
+    assert "已使用" in str(failed_claims[0])
+    resumable = await service_db.preview_review_qr(token, customer.id)
+    assert resumable["status"] == "claimed"
+
+    verified_review = await service_db.submit_review(
+        customer=customer,
+        technician_id=profile["user_id"],
+        source="qr_verified",
+        answers={
+            "service_effect": 5,
+            "professionalism": 5,
+            "communication": 5,
+            "comment": "已完成服务单评价",
+        },
+        anonymous=False,
+        qr_id=claimed["qr_id"],
+    )
+    assert verified_review["source"] == "qr_verified"
+    assert verified_review["order_id"] == order["order_id"]
+    with pytest.raises(service_db.ServiceDataError, match="无效"):
+        await service_db.preview_review_qr(token, customer.id)
+    with pytest.raises(service_db.ServiceDataError, match="已经提交评价"):
+        await service_db.submit_review(
+            customer=customer,
+            technician_id=profile["user_id"],
+            source="qr_verified",
+            answers={
+                "service_effect": 5,
+                "professionalism": 5,
+                "communication": 5,
+            },
+            anonymous=True,
+            qr_id=claimed["qr_id"],
+        )
+
+    address_after = await app_modules.wbb.control_db.service_order_addresses.find_one(
+        {"order_id": order["order_id"]}
+    )
+    assert address_after["redact_after"] - completed["closed_at"] == timedelta(days=7)
+    await app_modules.wbb.control_db.service_order_addresses.update_one(
+        {"order_id": order["order_id"]},
+        {"$set": {"redact_after": service_db.utc_now() - timedelta(seconds=1)}},
+    )
+    assert await service_db.redact_expired_addresses() == 1
+    redacted_order = await app_modules.wbb.control_db.service_orders.find_one(
+        {"order_id": order["order_id"]}
+    )
+    redacted_address = await app_modules.wbb.control_db.service_order_addresses.find_one(
+        {"order_id": order["order_id"]}
+    )
+    assert redacted_order["address_redacted"] is True
+    assert "distance_meters" not in redacted_order
+    assert "encrypted_payload" not in redacted_address
+
+
+async def test_state_machine_cancellation_and_template_snapshot(app_modules):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    profile = await _technician(app_modules, service_db)
+    customer = await _accepted_customer(service_db)
+
+    template = await service_db.create_package_template(
+        {"template_name": "护理模板", "package": _package(name="模板初版")},
+        actor_id="admin",
+    )
+    selected = await service_db.instantiate_package_template(template["template_id"])
+    assert selected["source_template_version"] == 1
+    await service_db.update_package_template(
+        template["template_id"],
+        {"template_name": "护理模板", "package": _package(name="模板新版")},
+        actor_id="admin",
+    )
+    assert selected["name"] == "模板初版"
+
+    order = await _order(service_db, customer, profile, service_mode="at_store")
+    assert order["distance_meters"] > 0
+    assert order["distance_band"] == "1 公里内"
+    with pytest.raises(service_db.ServiceDataError, match="只有已确认"):
+        await service_db.start_service_order(order["order_id"], profile["user_id"])
+    canceled = await service_db.cancel_service_order(
+        order["order_id"], customer.id, "顾客计划调整"
+    )
+    assert canceled["status"] == "canceled_customer"
+    with pytest.raises(service_db.ServiceDataError, match="不能取消"):
+        await service_db.cancel_service_order(
+            order["order_id"], profile["user_id"], "重复取消"
+        )
+
+    pending_order = await _order(service_db, customer, profile)
+    await service_db.submit_service_quote(
+        pending_order["order_id"],
+        profile["user_id"],
+        {"base_amount": "100", "travel_fee": "0", "discount": "0"},
+    )
+    await service_db.confirm_service_quote(pending_order["order_id"], customer.id)
+    await service_db.start_service_order(pending_order["order_id"], profile["user_id"])
+    _, pending_token = await service_db.issue_review_qr(
+        pending_order["order_id"], profile["user_id"]
+    )
+    canceled_pending = await service_db.cancel_service_order(
+        pending_order["order_id"], profile["user_id"], "服务现场无法继续"
+    )
+    assert canceled_pending["status"] == "canceled_technician"
+    with pytest.raises(service_db.ServiceDataError, match="无效"):
+        await service_db.preview_review_qr(pending_token, customer.id)
+
+    disputed_order = await _order(service_db, customer, profile)
+    disputed = await service_db.dispute_service_order(
+        disputed_order["order_id"], customer.id, "需要管理员协调"
+    )
+    assert disputed["status"] == "disputed"
+    resumed = await service_db.resolve_service_dispute(
+        disputed_order["order_id"],
+        actor_id="admin",
+        action="resume",
+        reason="双方已达成一致",
+    )
+    assert resumed["status"] == "requested"
+
+
+async def test_order_requires_encryption_before_persisting(app_modules):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    profile = await _technician(app_modules, service_db)
+    customer = await _accepted_customer(service_db)
+    original_key = app_modules.wbb.SERVICE_ADDRESS_ENCRYPTION_KEY
+    app_modules.wbb.SERVICE_ADDRESS_ENCRYPTION_KEY = ""
+    try:
+        with pytest.raises(service_db.ServiceDataError, match="加密密钥"):
+            await _order(service_db, customer, profile)
+    finally:
+        app_modules.wbb.SERVICE_ADDRESS_ENCRYPTION_KEY = original_key
+    assert (
+        await app_modules.wbb.control_db.service_orders.count_documents(
+            {"customer_id": customer.id}
+        )
+        == 0
+    )
+
+
+async def test_expired_review_qr_reopens_order_for_reissue(app_modules):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    profile = await _technician(app_modules, service_db)
+    customer = await _accepted_customer(service_db)
+    order = await _order(service_db, customer, profile)
+    await service_db.submit_service_quote(
+        order["order_id"],
+        profile["user_id"],
+        {"base_amount": "100", "travel_fee": "0", "discount": "0"},
+    )
+    await service_db.confirm_service_quote(order["order_id"], customer.id)
+    await service_db.start_service_order(order["order_id"], profile["user_id"])
+    first_qr, first_token = await service_db.issue_review_qr(
+        order["order_id"], profile["user_id"]
+    )
+    await app_modules.wbb.control_db.teacher_review_qr_records.update_one(
+        {"qr_id": first_qr["qr_id"]},
+        {"$set": {"expires_at": service_db.utc_now() - timedelta(seconds=1)}},
+    )
+
+    with pytest.raises(service_db.ServiceDataError, match="已过期"):
+        await service_db.preview_review_qr(first_token, customer.id)
+    reopened = await app_modules.wbb.control_db.service_orders.find_one(
+        {"order_id": order["order_id"]}
+    )
+    assert reopened["status"] == "in_progress"
+    assert "current_review_qr_id" not in reopened
+
+    replacement, replacement_token = await service_db.issue_review_qr(
+        order["order_id"], profile["user_id"]
+    )
+    assert replacement["qr_id"] != first_qr["qr_id"]
+    assert replacement_token != first_token
+
+
+async def test_reviews_same_weight_anonymous_and_bayesian_leaderboard(app_modules):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    profile = await _technician(app_modules, service_db)
+    customer = await _accepted_customer(service_db)
+    package_id = profile["service_profile"]["packages"][0]["package_id"]
+    answers = {"service_effect": 5, "professionalism": 4, "communication": 5, "comment": "服务很专业"}
+
+    template = await service_db.get_active_review_template()
+    draft = await service_db.begin_review_draft(
+        customer_id=customer.id,
+        technician_id=profile["user_id"],
+        source="student_initiated",
+        package_id=package_id,
+        template=template,
+    )
+    await service_db.save_review_draft_progress(
+        customer.id,
+        answers={"service_effect": 5},
+        rating_index=1,
+        text_index=0,
+    )
+    resumed_draft = await service_db.begin_review_draft(
+        customer_id=customer.id,
+        technician_id=profile["user_id"],
+        source="student_initiated",
+        package_id=package_id,
+        template=template,
+    )
+    assert resumed_draft["draft_id"] == draft["draft_id"]
+    assert resumed_draft["answers"] == {"service_effect": 5}
+    await service_db.delete_review_draft(customer.id)
+
+    review_ids = []
+    for _ in range(3):
+        review = await service_db.submit_review(
+            customer=customer,
+            technician_id=profile["user_id"],
+            source="student_initiated",
+            answers=answers,
+            anonymous=True,
+            package_id=package_id,
+        )
+        review_ids.append(review["review_id"])
+        assert "qr_id" not in review
+    assert len(set(review_ids)) == 3
+    for review_id in review_ids:
+        await service_db.moderate_review(
+            review_id, "approve", actor_id="admin"
+        )
+
+    public, total = await service_db.list_public_reviews(profile["user_id"])
+    assert total == 3
+    assert all(item["customer_name"] == "匿名顾客" for item in public)
+    assert all(item["source_label"] == "用户主动评价" for item in public)
+    summary = await service_db.get_technician_review_summary(profile["user_id"])
+    assert summary["review_count"] == 3
+
+    pending, _ = await service_db.list_reviews(
+        status="approved", source="student_initiated"
+    )
+    assert all(item["similar_content_count"] == 3 for item in pending)
+
+    leaderboard, total = await service_db.list_leaderboard(category="护理")
+    assert total == 1
+    assert leaderboard[0]["review_count"] == 3
+    assert leaderboard[0]["eligible"] is True
+    assert leaderboard[0]["rank_score"] == pytest.approx(
+        leaderboard[0]["average_score"]
+    )
+
+
+async def test_review_moderation_only_allows_defined_state_transitions(app_modules):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    profile = await _technician(app_modules, service_db)
+    customer = await _accepted_customer(service_db)
+    package_id = profile["service_profile"]["packages"][0]["package_id"]
+    review = await service_db.submit_review(
+        customer=customer,
+        technician_id=profile["user_id"],
+        source="student_initiated",
+        answers={
+            "service_effect": 5,
+            "professionalism": 5,
+            "communication": 5,
+        },
+        anonymous=True,
+        package_id=package_id,
+    )
+    approved = await service_db.moderate_review(
+        review["review_id"], "approve", actor_id="admin"
+    )
+    assert approved["status"] == "approved"
+    with pytest.raises(service_db.ServiceDataError, match="不能执行"):
+        await service_db.moderate_review(
+            review["review_id"], "reject", actor_id="admin", reason="重复处理"
+        )
+    voided = await service_db.moderate_review(
+        review["review_id"], "void", actor_id="admin", reason="争议作废"
+    )
+    assert voided["status"] == "voided"
+    with pytest.raises(service_db.ServiceDataError, match="不能执行"):
+        await service_db.moderate_review(
+            review["review_id"], "approve", actor_id="admin"
+        )
+
+
+async def test_technician_requires_active_managed_group_membership(app_modules):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    profile = await _technician(app_modules, service_db)
+    await app_modules.wbb.control_db.directory_memberships.update_many(
+        {"user_id": profile["user_id"]}, {"$set": {"active": False}}
+    )
+    customer = await _accepted_customer(service_db)
+    with pytest.raises(service_db.ServiceDataError, match="当前不满足接单资格"):
+        await _order(service_db, customer, profile)
+
+
+async def test_service_admin_api_settings_templates_and_permissions(app_modules):
+    admin_api = app_modules.load("wbb.admin.api")
+    app_modules.wbb.BOT_PERMISSIONS = {"teacher_directory.manage"}
+    application = admin_api.build_admin_application()
+    await application["admin_api"].initialize()
+    client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
+    await client.start_server()
+    try:
+        login = await client.post(
+            "/api/admin/v1/auth/login",
+            json={"username": "admin", "password": "qwe0.123456"},
+        )
+        login_data = (await login.json())["data"]
+        changed = await client.put(
+            "/api/admin/v1/auth/password",
+            headers={"X-CSRF-Token": login_data["csrf_token"]},
+            json={
+                "current_password": "qwe0.123456",
+                "new_password": "changed-pass-123",
+            },
+        )
+        headers = {"X-CSRF-Token": (await changed.json())["data"]["csrf_token"]}
+
+        unconfirmed = await client.put(
+            "/api/admin/v1/directory/service-settings",
+            headers=headers,
+            json={"customer_max_open_orders": 4},
+        )
+        assert unconfirmed.status == 409
+        saved = await client.put(
+            "/api/admin/v1/directory/service-settings",
+            headers=headers,
+            json={
+                "customer_max_open_orders": 4,
+                "customer_daily_request_limit": 12,
+                "review_qr_expiry_hours": 48,
+                "confirm": True,
+            },
+        )
+        assert saved.status == 200
+        assert (await saved.json())["data"]["review_qr_expiry_hours"] == 48
+
+        created = await client.post(
+            "/api/admin/v1/directory/package-templates",
+            headers=headers,
+            json={
+                "template_name": "后台护理模板",
+                "package": _package(),
+                "confirm": True,
+            },
+        )
+        assert created.status == 201
+        created_data = (await created.json())["data"]
+        assert created_data["version"] == 1
+        duplicated = await client.post(
+            f"/api/admin/v1/directory/package-templates/{created_data['template_id']}/actions",
+            headers=headers,
+            json={"action": "duplicate", "confirm": True},
+        )
+        assert duplicated.status == 200
+        assert (await duplicated.json())["data"]["status"] == "disabled"
+
+        review_template = await client.get(
+            "/api/admin/v1/directory/review-template"
+        )
+        assert review_template.status == 200
+        assert sum(
+            item["weight"]
+            for item in (await review_template.json())["data"]["rating_questions"]
+        ) == 100
+
+        metrics = await client.get(
+            "/api/admin/v1/directory/fulfillment-metrics"
+        )
+        assert metrics.status == 200
+        assert (await metrics.json())["data"]["scope_note"].startswith(
+            "仅统计平台服务单"
+        )
+
+        app_modules.wbb.BOT_PERMISSIONS = set()
+        denied = await client.get(
+            "/api/admin/v1/directory/fulfillment-metrics"
+        )
+        assert denied.status == 403
+    finally:
+        await client.close()

+ 145 - 0
uv.lock

@@ -189,6 +189,91 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
 ]
 
+[[package]]
+name = "cffi"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
+    { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
+    { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
+    { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
+    { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
+    { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
+    { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
+    { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
+    { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
+    { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
+    { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
+    { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
+    { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
+    { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
+    { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
+    { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
+    { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
+    { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
+    { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
+    { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
+    { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
+    { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
+    { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
+]
+
 [[package]]
 name = "charset-normalizer"
 version = "3.4.4"
@@ -267,6 +352,41 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
 ]
 
+[[package]]
+name = "cryptography"
+version = "45.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" },
+    { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244, upload-time = "2025-09-01T11:14:08.152Z" },
+    { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975, upload-time = "2025-09-01T11:14:09.755Z" },
+    { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082, upload-time = "2025-09-01T11:14:11.229Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" },
+    { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" },
+    { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" },
+    { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" },
+    { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893, upload-time = "2025-09-01T11:14:27.1Z" },
+    { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132, upload-time = "2025-09-01T11:14:28.58Z" },
+    { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086, upload-time = "2025-09-01T11:14:30.572Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" },
+    { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" },
+    { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" },
+]
+
 [[package]]
 name = "deprecated"
 version = "1.3.1"
@@ -944,6 +1064,15 @@ version = "1.6.1"
 source = { registry = "https://pypi.org/simple" }
 sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" }
 
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
 [[package]]
 name = "pygments"
 version = "2.20.0"
@@ -1107,6 +1236,18 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002, upload-time = "2024-09-11T02:24:45.8Z" },
 ]
 
+[[package]]
+name = "qrcode"
+version = "8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
+]
+
 [[package]]
 name = "requests"
 version = "2.33.1"
@@ -1253,6 +1394,7 @@ dependencies = [
     { name = "aiofiles" },
     { name = "aiohttp" },
     { name = "bs4" },
+    { name = "cryptography" },
     { name = "dnspython" },
     { name = "feedparser" },
     { name = "ffmpeg-python" },
@@ -1270,6 +1412,7 @@ dependencies = [
     { name = "python-arq" },
     { name = "python-dotenv" },
     { name = "pytz" },
+    { name = "qrcode" },
     { name = "requests" },
     { name = "search-engine-parser" },
     { name = "speedtest-cli" },
@@ -1292,6 +1435,7 @@ requires-dist = [
     { name = "aiofiles", specifier = "~=23.2.1" },
     { name = "aiohttp", specifier = "~=3.14.1" },
     { name = "bs4", specifier = "~=0.0.2" },
+    { name = "cryptography", specifier = "~=45.0" },
     { name = "dnspython", specifier = "==2.6.1" },
     { name = "feedparser", specifier = "~=6.0.12" },
     { name = "ffmpeg-python", specifier = "~=0.2.0" },
@@ -1309,6 +1453,7 @@ requires-dist = [
     { name = "python-arq", specifier = "~=6.0.7" },
     { name = "python-dotenv", specifier = "~=1.2.2" },
     { name = "pytz", specifier = "~=2024.1" },
+    { name = "qrcode", specifier = "~=8.2" },
     { name = "requests", specifier = "~=2.33.0" },
     { name = "search-engine-parser", specifier = "~=0.6.8" },
     { name = "speedtest-cli", specifier = "~=2.1.3" },

+ 1 - 0
wbb/__init__.py

@@ -74,6 +74,7 @@ MOD_LOAD = [
     "points",
     "rules",
     "teacher_directory",
+    "technician_services",
 ]
 MOD_NOLOAD = []
 SUDOERS = filters.user()

+ 34 - 15
wbb/__main__.py

@@ -100,9 +100,15 @@ BOT_COMMANDS = [
     BotCommand("gcancel", "取消抽奖并退款"),
     BotCommand("greroll", "重新抽取中奖者"),
     BotCommand("rules", "查看本群群规"),
-    BotCommand("directory", "打开附近老师菜单"),
-    BotCommand("nearby", "查找附近老师"),
-    BotCommand("teacher_rank", "查看老师榜单"),
+    BotCommand("directory", "打开附近技师菜单"),
+    BotCommand("nearby", "查找附近技师"),
+    BotCommand("teacher_rank", "查看技师榜单"),
+    BotCommand("find_technician", "查找附近技师"),
+    BotCommand("my_services", "查看我的服务单"),
+    BotCommand("technician_profile", "管理技师服务资料"),
+    BotCommand("review_teacher", "主动评价技师"),
+    BotCommand("technician_top", "查看审核评价排行"),
+    BotCommand("review_qr", "生成完成评价二维码"),
 ]
 BOT_COMMAND_PERMISSIONS = {
     "manage": "__group_any__",
@@ -122,6 +128,12 @@ BOT_COMMAND_PERMISSIONS = {
     "directory": "teacher_directory.manage",
     "nearby": "teacher_directory.manage",
     "teacher_rank": "teacher_directory.manage",
+    "find_technician": "teacher_directory.manage",
+    "my_services": "teacher_directory.manage",
+    "technician_profile": "teacher_directory.manage",
+    "review_teacher": "teacher_directory.manage",
+    "technician_top": "teacher_directory.manage",
+    "review_qr": "teacher_directory.manage",
 }
 
 
@@ -211,10 +223,10 @@ async def start_bot():
     j = 1
     for i in ALL_MODULES:
         if j == 4:
-            bot_modules += "|{:<15}|\n".format(i)
+            bot_modules += f"|{i:<15}|\n"
             j = 0
         else:
-            bot_modules += "|{:<15}".format(i)
+            bot_modules += f"|{i:<15}"
         j += 1
     print("+===============================================================+")
     print("|                              WBB                              |")
@@ -270,7 +282,7 @@ if has_any_permission(GROUP_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS):
 if has_permission("teacher_directory.manage", BOT_PERMISSIONS):
     home_actions.append(
         InlineKeyboardButton(
-            text="附近老师",
+            text="附近技师",
             url=f"https://t.me/{BOT_USERNAME}?start=directory",
         )
     )
@@ -307,7 +319,7 @@ assigned_features = [
         ("points.manage", "积分"),
         ("giveaways.manage", "抽奖"),
         ("karma.manage", "声望"),
-        ("teacher_directory.manage", "老师与附近"),
+        ("teacher_directory.manage", "技师与附近"),
     )
     if has_permission(permission, BOT_PERMISSIONS)
 ]
@@ -369,7 +381,17 @@ async def start(_, message):
         )
     if len(message.text.split()) > 1:
         user = await app.get_users(message.from_user.id)
-        name = (message.text.split(None, 1)[1]).lower()
+        start_parameter = message.text.split(None, 1)[1]
+        if start_parameter.startswith("review_") and has_permission(
+            "teacher_directory.manage", BOT_PERMISSIONS
+        ):
+            from wbb.modules.technician_services import open_review_qr
+
+            return await open_review_qr(
+                message,
+                start_parameter.removeprefix("review_"),
+            )
+        name = start_parameter.lower()
         match = re.match(r"rules_(.*)", name)
         if match:
             chat_id = match.group(1)
@@ -448,9 +470,9 @@ async def start(_, message):
             reply_markup=home_keyboard_pm,
         )
         if has_permission("teacher_directory.manage", BOT_PERMISSIONS):
-            from wbb.modules.teacher_directory import send_directory_menu
+            from wbb.modules.technician_services import send_service_home
 
-            await send_directory_menu(message)
+            await send_service_home(message)
     return
 
 
@@ -514,13 +536,10 @@ async def help_parser(name, keyboard=None):
     if not keyboard:
         keyboard = InlineKeyboardMarkup(paginate_modules(0, HELPABLE, "help"))
     return (
-        """你好,{first_name},我是 {bot_name}。
+        f"""你好,{name},我是 {BOT_NAME}。
 我可以管理群组、自动回复、积分和抽奖。
 请选择下方功能分类查看中文命令说明。
-""".format(
-            first_name=name,
-            bot_name=BOT_NAME,
-        ),
+""",
         keyboard,
     )
 

+ 452 - 9
wbb/admin/api.py

@@ -116,6 +116,33 @@ from wbb.utils.dbpoints import (
     list_point_transactions,
     set_points,
 )
+from wbb.utils.dbservice import (
+    ServiceDataError,
+    admin_reveal_order_address,
+    create_package_template,
+    duplicate_package_template,
+    fulfillment_metrics,
+    get_active_review_template,
+    get_admin_service_order,
+    get_service_settings,
+    get_technician_service_profile,
+    list_customer_blocks,
+    list_leaderboard,
+    list_package_templates,
+    list_review_qr_records,
+    list_reviews,
+    list_service_orders,
+    list_service_reports,
+    moderate_review,
+    moderate_service_report,
+    publish_review_template,
+    publish_technician_profile,
+    resolve_service_dispute,
+    set_customer_block,
+    set_package_template_status,
+    set_service_settings,
+    update_package_template,
+)
 
 API_PREFIX = "/api/admin/v1"
 SESSION_COOKIE = "wbb_admin_session"
@@ -132,8 +159,11 @@ TELEGRAM_ID_KEYS = {
     "user_id",
     "actor_id",
     "creator_id",
+    "customer_id",
     "moderator_id",
+    "reporter_id",
     "target_id",
+    "technician_id",
     "message_id",
     "removed_by",
     "authorization_chat_id",
@@ -168,7 +198,7 @@ def jsonable(value: Any, *, key: str = "") -> Any:
         return {
             item_key: jsonable(item_value, key=item_key)
             for item_key, item_value in value.items()
-            if item_key != "_id"
+            if item_key not in {"_id", "encrypted_payload", "token_hash"}
         }
     if isinstance(value, (list, tuple)):
         return [jsonable(item) for item in value]
@@ -275,7 +305,7 @@ async def api_error_middleware(request: web.Request, handler):
         )
         await _record_request_audit(request, success_state=False, error=str(exc))
         return error_response(problem)
-    except DirectoryDataError as exc:
+    except (DirectoryDataError, ServiceDataError) as exc:
         problem = ApiProblem(exc.code, str(exc), status=409)
         await _record_request_audit(request, success_state=False, error=str(exc))
         return error_response(problem)
@@ -597,6 +627,92 @@ class AdminApi:
             self.directory_location_delete,
         )
         router.add_get(f"{API_PREFIX}/directory/events", self.directory_events)
+        router.add_get(
+            f"{API_PREFIX}/directory/service-settings",
+            self.service_settings,
+        )
+        router.add_put(
+            f"{API_PREFIX}/directory/service-settings",
+            self.service_settings_update,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/package-templates",
+            self.service_package_templates,
+        )
+        router.add_post(
+            f"{API_PREFIX}/directory/package-templates",
+            self.service_package_template_create,
+        )
+        router.add_put(
+            f"{API_PREFIX}/directory/package-templates/{{template_id}}",
+            self.service_package_template_update,
+        )
+        router.add_post(
+            f"{API_PREFIX}/directory/package-templates/{{template_id}}/actions",
+            self.service_package_template_action,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/profiles/{{user_id}}/service-profile",
+            self.technician_service_profile,
+        )
+        router.add_put(
+            f"{API_PREFIX}/directory/profiles/{{user_id}}/service-profile",
+            self.technician_service_profile_update,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/service-orders",
+            self.service_orders,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/service-orders/{{order_id}}",
+            self.service_order_detail,
+        )
+        router.add_post(
+            f"{API_PREFIX}/directory/service-orders/{{order_id}}/actions",
+            self.service_order_action,
+        )
+        router.add_post(
+            f"{API_PREFIX}/directory/service-orders/{{order_id}}/address",
+            self.service_order_address,
+        )
+        router.add_post(
+            f"{API_PREFIX}/directory/customer-blocks/{{customer_id}}",
+            self.service_customer_block,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/customer-blocks",
+            self.service_customer_blocks,
+        )
+        router.add_get(f"{API_PREFIX}/directory/reports", self.service_reports)
+        router.add_post(
+            f"{API_PREFIX}/directory/reports/{{report_id}}/actions",
+            self.service_report_action,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/review-template",
+            self.service_review_template,
+        )
+        router.add_put(
+            f"{API_PREFIX}/directory/review-template",
+            self.service_review_template_publish,
+        )
+        router.add_get(f"{API_PREFIX}/directory/reviews", self.service_reviews)
+        router.add_post(
+            f"{API_PREFIX}/directory/reviews/{{review_id}}/actions",
+            self.service_review_action,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/review-qrs",
+            self.service_review_qrs,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/leaderboard",
+            self.service_leaderboard,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/fulfillment-metrics",
+            self.service_fulfillment_metrics,
+        )
 
         router.add_get(f"{API_PREFIX}/giveaways", self.giveaways)
         router.add_post(f"{API_PREFIX}/giveaways", self.giveaway_create)
@@ -766,7 +882,7 @@ class AdminApi:
         set_audit(
             request,
             "directory.settings.update",
-            summary="更新老师目录设置",
+            summary="更新技师目录设置",
         )
         return success(saved)
 
@@ -809,13 +925,13 @@ class AdminApi:
             request,
             f"directory.application.{action}",
             target_id=user_id,
-            summary=f"{labels.get(action, action)}老师申请",
+            summary=f"{labels.get(action, action)}技师申请",
             metadata={"reason": reason},
         )
         notification = {
-            "approve": "你的老师申请已通过,可以在菜单中自助上榜。",
-            "reject": f"你的老师申请未通过。原因:{reason}",
-            "revoke": f"你的老师资格已被撤销。原因:{reason}",
+            "approve": "你的技师申请已通过,可以在菜单中自助上榜。",
+            "reject": f"你的技师申请未通过。原因:{reason}",
+            "revoke": f"你的技师资格已被撤销。原因:{reason}",
         }.get(action)
         if notification:
             await self._notify_directory_user(profile=profile, text=notification)
@@ -867,12 +983,12 @@ class AdminApi:
             request,
             f"directory.profile.{action}",
             target_id=user_id,
-            summary=f"强制修改老师状态:{action}",
+            summary=f"强制修改技师状态:{action}",
             metadata={"reason": reason},
         )
         await self._notify_directory_user(
             profile=profile,
-            text=f"管理员已将你的老师状态修改为“{action}”。原因:{reason}",
+            text=f"管理员已将你的技师状态修改为“{action}”。原因:{reason}",
         )
         return success(profile)
 
@@ -982,6 +1098,333 @@ class AdminApi:
             }
         )
 
+    async def service_settings(self, _: web.Request) -> web.Response:
+        return success(await get_service_settings())
+
+    async def service_settings_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        saved = await set_service_settings(body)
+        set_audit(request, "service.settings.update", summary="更新履约服务设置")
+        return success(saved)
+
+    async def service_package_templates(self, request: web.Request) -> web.Response:
+        items = await list_package_templates(
+            include_archived=request.query.get("include_archived", "false").lower()
+            == "true"
+        )
+        return success({"items": items, "total": len(items)})
+
+    async def service_package_template_create(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        created = await create_package_template(
+            body,
+            actor_id=request["admin"]["username"],
+        )
+        set_audit(
+            request,
+            "service.package_template.create",
+            target_id=created["template_id"],
+            summary="创建套餐模板",
+        )
+        return success(created, status=201)
+
+    async def service_package_template_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        template_id = request.match_info["template_id"]
+        updated = await update_package_template(
+            template_id,
+            body,
+            actor_id=request["admin"]["username"],
+        )
+        set_audit(
+            request,
+            "service.package_template.update",
+            target_id=template_id,
+            summary="更新套餐模板并创建新版本",
+        )
+        return success(updated)
+
+    async def service_package_template_action(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        template_id = request.match_info["template_id"]
+        action = str(body.get("action") or "")
+        if action == "duplicate":
+            updated = await duplicate_package_template(
+                template_id,
+                actor_id=request["admin"]["username"],
+            )
+        else:
+            updated = await set_package_template_status(
+                template_id,
+                action,
+                actor_id=request["admin"]["username"],
+            )
+        set_audit(
+            request,
+            f"service.package_template.{action}",
+            target_id=template_id,
+            summary=f"套餐模板操作:{action}",
+        )
+        return success(updated)
+
+    async def technician_service_profile(self, request: web.Request) -> web.Response:
+        user_id = parse_int(request.match_info["user_id"], "user_id", minimum=1)
+        return success(await get_technician_service_profile(user_id))
+
+    async def technician_service_profile_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        user_id = parse_int(request.match_info["user_id"], "user_id", minimum=1)
+        values = body.get("service_profile") or {
+            key: value for key, value in body.items() if key != "confirm"
+        }
+        updated = await publish_technician_profile(
+            user_id,
+            values,
+            actor_id=request["admin"]["username"],
+        )
+        set_audit(
+            request,
+            "service.technician_profile.update",
+            target_id=user_id,
+            summary="更新技师服务资料",
+        )
+        return success(updated)
+
+    async def service_orders(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_service_orders(
+            query=str(request.query.get("query") or ""),
+            status=str(request.query.get("status") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {"items": items, "total": total, "page": page, "page_size": page_size}
+        )
+
+    async def service_order_detail(self, request: web.Request) -> web.Response:
+        return success(await get_admin_service_order(request.match_info["order_id"]))
+
+    async def service_order_action(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        action = str(body.get("action") or "")
+        reason = str(body.get("reason") or "").strip()
+        if not reason:
+            raise ApiProblem("reason_required", "争议处理或作废必须填写原因。")
+        order_id = request.match_info["order_id"]
+        updated = await resolve_service_dispute(
+            order_id,
+            actor_id=request["admin"]["username"],
+            action=action,
+            reason=reason,
+        )
+        set_audit(
+            request,
+            f"service.order.{action}",
+            target_id=order_id,
+            summary=f"服务单操作:{action}",
+            metadata={"reason": reason},
+        )
+        return success(updated)
+
+    async def service_order_address(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        reason = str(body.get("reason") or "").strip()
+        if not reason:
+            raise ApiProblem("reason_required", "查看精确地址必须填写原因。")
+        order_id = request.match_info["order_id"]
+        address = await admin_reveal_order_address(
+            order_id,
+            actor_id=request["admin"]["username"],
+            reason=reason,
+        )
+        set_audit(
+            request,
+            "service.order.address_reveal",
+            target_id=order_id,
+            summary="管理员查看服务单精确地址",
+            metadata={"reason": reason},
+        )
+        return success(address)
+
+    async def service_customer_block(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        customer_id = parse_int(
+            request.match_info["customer_id"], "customer_id", minimum=1
+        )
+        active = bool(body.get("active", True))
+        reason = str(body.get("reason") or "").strip()
+        if active and not reason:
+            raise ApiProblem("reason_required", "封禁顾客必须填写原因。")
+        updated = await set_customer_block(
+            customer_id=customer_id,
+            scope="global",
+            technician_id=None,
+            active=active,
+            actor_id=request["admin"]["username"],
+            reason=reason,
+        )
+        set_audit(
+            request,
+            "service.customer.block" if active else "service.customer.unblock",
+            target_id=customer_id,
+            summary="全局封禁顾客" if active else "解除顾客封禁",
+            metadata={"reason": reason},
+        )
+        return success(updated)
+
+    async def service_customer_blocks(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        active_raw = request.query.get("active")
+        active = (
+            active_raw.lower() in {"1", "true"}
+            if active_raw is not None
+            else None
+        )
+        items, total = await list_customer_blocks(
+            scope=str(request.query.get("scope") or "global"),
+            active=active,
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {"items": items, "total": total, "page": page, "page_size": page_size}
+        )
+
+    async def service_reports(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_service_reports(
+            status=str(request.query.get("status") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {"items": items, "total": total, "page": page, "page_size": page_size}
+        )
+
+    async def service_report_action(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        action = str(body.get("action") or "")
+        reason = str(body.get("reason") or "").strip()
+        if not reason:
+            raise ApiProblem("reason_required", "处理举报必须填写原因。")
+        report = await moderate_service_report(
+            request.match_info["report_id"],
+            action=action,
+            actor_id=request["admin"]["username"],
+            reason=reason,
+        )
+        set_audit(
+            request,
+            f"service.report.{action}",
+            target_id=report["report_id"],
+            summary=f"处理服务举报:{action}",
+            metadata={"reason": reason},
+        )
+        return success(report)
+
+    async def service_review_template(self, _: web.Request) -> web.Response:
+        return success(await get_active_review_template())
+
+    async def service_review_template_publish(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        published = await publish_review_template(
+            body,
+            actor_id=request["admin"]["username"],
+        )
+        set_audit(
+            request,
+            "service.review_template.publish",
+            target_id=published["template_id"],
+            summary="发布新版评价模板",
+        )
+        return success(published, status=201)
+
+    async def service_reviews(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_reviews(
+            status=str(request.query.get("status") or ""),
+            source=str(request.query.get("source") or ""),
+            query=str(request.query.get("query") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {"items": items, "total": total, "page": page, "page_size": page_size}
+        )
+
+    async def service_review_action(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        action = str(body.get("action") or "")
+        reason = str(body.get("reason") or "").strip()
+        updated = await moderate_review(
+            request.match_info["review_id"],
+            action,
+            actor_id=request["admin"]["username"],
+            reason=reason,
+        )
+        set_audit(
+            request,
+            f"service.review.{action}",
+            target_id=request.match_info["review_id"],
+            summary=f"评价审核:{action}",
+            metadata={"reason": reason},
+        )
+        with suppress(Exception):
+            await telegram_app.send_message(
+                int(updated["customer_id"]),
+                (
+                    "你的评价已审核通过。"
+                    if action == "approve"
+                    else f"你的评价未通过审核。原因:{reason}"
+                ),
+            )
+        return success(updated)
+
+    async def service_review_qrs(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_review_qr_records(
+            status=str(request.query.get("status") or ""),
+            query=str(request.query.get("query") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {"items": items, "total": total, "page": page, "page_size": page_size}
+        )
+
+    async def service_leaderboard(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_leaderboard(
+            category=str(request.query.get("category") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {
+                "items": items,
+                "total": total,
+                "page": page,
+                "page_size": page_size,
+                "rule": "v/(v+5) × 技师平均分 + 5/(v+5) × 分类平均分;至少 3 条批准评价。",
+                "title": "审核评价排行",
+            }
+        )
+
+    async def service_fulfillment_metrics(self, _: web.Request) -> web.Response:
+        return success(await fulfillment_metrics())
+
     def _set_session_cookie(self, response: web.Response, token: str) -> None:
         response.set_cookie(
             SESSION_COOKIE,

+ 57 - 57
wbb/modules/teacher_directory.py

@@ -51,22 +51,22 @@ from wbb.utils.dbdirectory import (
     list_teacher_applications,
 )
 
-__MODULE__ = "老师与附近"
-__HELP__ = """/directory - 打开附近老师菜单。
-/nearby - 选择范围并查找附近老师。
-/teacher_rank - 按距离查看全部上榜老师。
-/teacher_apply - 申请成为老师。
-/teacher_online - 老师上线。
-/teacher_offline - 老师下线。
-/teacher_list - 老师上榜。
-/teacher_unlist - 老师下榜。"""
+__MODULE__ = "技师与附近"
+__HELP__ = """/directory - 打开附近技师菜单。
+/nearby - 选择范围并查找附近技师。
+/teacher_rank - 按距离查看全部上榜技师。
+/teacher_apply - 申请成为技师。
+/teacher_online - 技师上线。
+/teacher_offline - 技师下线。
+/teacher_list - 技师上榜。
+/teacher_unlist - 技师下榜。"""
 
 APPLICATION_LABELS = {
     "none": "未申请",
-    APPLICATION_PENDING: "老师申请待审批",
-    APPLICATION_APPROVED: "老师",
-    APPLICATION_REJECTED: "老师申请已拒绝",
-    APPLICATION_REVOKED: "老师资格已撤销",
+    APPLICATION_PENDING: "技师申请待审批",
+    APPLICATION_APPROVED: "技师",
+    APPLICATION_REJECTED: "技师申请已拒绝",
+    APPLICATION_REVOKED: "技师资格已撤销",
 }
 TEACHER_ACTION_LABELS = {
     "list": "上榜",
@@ -74,10 +74,10 @@ TEACHER_ACTION_LABELS = {
     "online": "上线",
     "offline": "下线",
 }
-BUTTON_NEARBY = "附近老师"
-BUTTON_RANK = "老师榜单"
-BUTTON_APPLY = "申请成为老师"
-BUTTON_APPROVAL = "老师审批"
+BUTTON_NEARBY = "附近技师"
+BUTTON_RANK = "技师榜单"
+BUTTON_APPLY = "申请成为技师"
+BUTTON_APPROVAL = "技师审批"
 BUTTON_STATUS = "我的状态"
 BUTTON_LIST = "上榜"
 BUTTON_UNLIST = "下榜"
@@ -128,7 +128,7 @@ def _directory_keyboard(
         rows.append(
             [
                 KeyboardButton(
-                    "老师申请审核中"
+                    "技师申请审核中"
                     if status == APPLICATION_PENDING
                     else BUTTON_APPLY
                 )
@@ -142,7 +142,7 @@ def _directory_keyboard(
         resize_keyboard=True,
         is_persistent=True,
         selective=True,
-        placeholder="选择附近老师服务或输入消息",
+        placeholder="选择附近技师服务或输入消息",
     )
 
 
@@ -187,7 +187,7 @@ def _group_directory_keyboard(
         )
     elif status == APPLICATION_PENDING:
         rows.append(
-            [InlineKeyboardButton("老师申请审核中", callback_data="dir:status")]
+            [InlineKeyboardButton("技师申请审核中", callback_data="dir:status")]
         )
     else:
         rows.append(
@@ -206,24 +206,24 @@ def _presence_label(online: bool) -> str:
 
 def _menu_text(summary: dict[str, Any]) -> str:
     lines = [
-        "<b>附近老师</b>",
+        "<b>附近技师</b>",
         f"位置:{'已更新' if summary['has_location'] else '尚未更新'}",
     ]
     status = summary["application_status"]
     if status == APPLICATION_APPROVED:
         lines.extend(
             [
-                "身份:老师",
+                "身份:技师",
                 f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
                 f"状态:{_presence_label(summary['online'])}",
             ]
         )
     elif status == APPLICATION_PENDING:
-        lines.append("老师申请:待审批")
+        lines.append("技师申请:待审批")
     elif status == APPLICATION_REJECTED:
-        lines.append("老师申请:未通过")
+        lines.append("技师申请:未通过")
     elif status == APPLICATION_REVOKED:
-        lines.append("老师资格:已撤销")
+        lines.append("技师资格:已撤销")
     return "\n".join(lines)
 
 
@@ -471,7 +471,7 @@ async def directory_location_message(_, message: Message):
     except DirectoryServiceError as exc:
         return await message.reply_text(str(exc))
     _location_flows.pop(message.from_user.id, None)
-    await message.reply_text("Telegram 位置已更新,可立即查找附近老师。")
+    await message.reply_text("Telegram 位置已更新,可立即查找附近技师。")
     await _refresh_keyboard(message, await get_directory_profile(message.from_user.id))
 
 
@@ -535,13 +535,13 @@ async def _notify_application_admins(profile: dict[str, Any]) -> None:
             if user and not getattr(user, "is_bot", False):
                 admins.append(user)
     except Exception as exc:
-        log.error(f"获取老师申请来源群管理员失败:{exc}")
+        log.error(f"获取技师申请来源群管理员失败:{exc}")
     mentions = " ".join(_admin_mention(user) for user in admins[:20])
     applicant = escape(_display_name(profile))
     text = (
         f"{mentions}\n" if mentions else ""
     ) + (
-        f"有新的老师申请待审批:<b>{applicant}</b>\n"
+        f"有新的技师申请待审批:<b>{applicant}</b>\n"
         f"用户 ID:<code>{profile['user_id']}</code>"
     )
     markup = InlineKeyboardMarkup(
@@ -562,10 +562,10 @@ async def _notify_application_admins(profile: dict[str, Any]) -> None:
             reply_markup=markup,
         )
     except Exception as exc:
-        log.error(f"发送老师申请群提醒失败:{exc}")
+        log.error(f"发送技师申请群提醒失败:{exc}")
     private_text = (
         f"群“{escape(profile.get('application_source_chat_title') or str(chat_id))}”"
-        f"有新的老师申请:<b>{applicant}</b>。"
+        f"有新的技师申请:<b>{applicant}</b>。"
     )
     for admin in admins:
         if int(admin.id) == int(profile["user_id"]):
@@ -643,9 +643,9 @@ async def _apply_teacher(target: Message | CallbackQuery) -> None:
             return await target.answer(str(exc), show_alert=True)
         return await target.reply_text(str(exc))
     response_text = (
-        "老师申请已提交,来源群管理员已收到审批提醒。"
+        "技师申请已提交,来源群管理员已收到审批提醒。"
         if created
-        else "老师申请正在等待管理员审批。"
+        else "技师申请正在等待管理员审批。"
     )
     response = await message.reply_text(response_text)
     if isinstance(target, CallbackQuery):
@@ -670,17 +670,17 @@ async def _show_status(target: Message | CallbackQuery) -> None:
     )
     if summary["application_status"] != APPLICATION_APPROVED:
         status_text = {
-            APPLICATION_PENDING: "老师申请正在等待管理员审批。",
-            APPLICATION_REJECTED: "老师申请未通过。",
-            APPLICATION_REVOKED: "老师资格已被撤销。",
-        }.get(summary["application_status"], "你尚未申请成为老师。")
+            APPLICATION_PENDING: "技师申请正在等待管理员审批。",
+            APPLICATION_REJECTED: "技师申请未通过。",
+            APPLICATION_REVOKED: "技师资格已被撤销。",
+        }.get(summary["application_status"], "你尚未申请成为技师。")
         response = await message.reply_text(status_text)
         if isinstance(target, CallbackQuery):
             await target.answer()
         schedule_group_cleanup(message, response)
         return
     lines = [
-        "<b>我的老师状态</b>",
+        "<b>我的技师状态</b>",
         f"位置:{'已更新' if summary['has_location'] else '尚未更新'}",
         f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
         f"上下线:{_presence_label(summary['online'])}",
@@ -724,10 +724,10 @@ async def _show_radius_picker(target: Message | CallbackQuery) -> None:
         ]
     )
     if isinstance(target, CallbackQuery):
-        await target.message.edit_text("请选择附近老师的搜索范围:", reply_markup=markup)
+        await target.message.edit_text("请选择附近技师的搜索范围:", reply_markup=markup)
     else:
         response = await target.reply_text(
-            "请选择附近老师的搜索范围:",
+            "请选择附近技师的搜索范围:",
             reply_markup=markup,
         )
         schedule_group_cleanup(target, response)
@@ -796,11 +796,11 @@ async def _show_teacher_results(
         return await target.reply_text(str(exc))
     teachers = [public_teacher(item) for item in raw_items]
     title = (
-        f"<b>{radius_km} 公里内的老师</b>"
+        f"<b>{radius_km} 公里内的技师</b>"
         if radius_km is not None
-        else "<b>老师榜单</b>"
+        else "<b>技师榜单</b>"
     )
-    lines = [title, f"共 {total} 位已上榜老师,按距离排序。"]
+    lines = [title, f"共 {total} 位已上榜技师,按距离排序。"]
     for index, item in enumerate(teachers, start=(page - 1) * 10 + 1):
         lines.append(
             f"\n{index}. <b>{escape(item['display_name'])}</b> "
@@ -809,7 +809,7 @@ async def _show_teacher_results(
             f"距离:{format_distance(item['distance_meters'])}"
         )
     if not teachers:
-        lines.append("\n当前范围内没有已上榜老师。")
+        lines.append("\n当前范围内没有已上榜技师。")
     markup = _teacher_result_markup(
         teachers,
         radius_key=str(radius_km) if radius_km is not None else "all",
@@ -854,7 +854,7 @@ async def _show_admin_applications(
         page=page,
         page_size=5,
     )
-    lines = ["<b>老师申请审批</b>", f"待审批:{total}"]
+    lines = ["<b>技师申请审批</b>", f"待审批:{total}"]
     rows: list[list[InlineKeyboardButton]] = []
     for item in items:
         lines.append(
@@ -888,7 +888,7 @@ async def _show_admin_applications(
             ]
         )
     rows.append(
-        [InlineKeyboardButton("已批准老师", callback_data="dir:admin:teachers:1")]
+        [InlineKeyboardButton("已批准技师", callback_data="dir:admin:teachers:1")]
     )
     markup = InlineKeyboardMarkup(rows)
     if isinstance(target, CallbackQuery):
@@ -948,7 +948,7 @@ async def _show_admin_teachers(target: CallbackQuery, page: int) -> None:
         rows.append(navigation)
     rows.append([InlineKeyboardButton("待审批", callback_data="dir:admin:pending:1")])
     await target.message.edit_text(
-        f"<b>已批准老师</b>\n共 {total} 人",
+        f"<b>已批准技师</b>\n共 {total} 人",
         parse_mode=ParseMode.HTML,
         reply_markup=InlineKeyboardMarkup(rows),
     )
@@ -1008,7 +1008,7 @@ async def _show_admin_teacher_detail(target: CallbackQuery, user_id: int) -> Non
                 ],
                 [
                     InlineKeyboardButton(
-                        "撤销老师资格",
+                        "撤销技师资格",
                         callback_data=f"dir:admin:flow:revoke:{user_id}",
                     )
                 ],
@@ -1075,9 +1075,9 @@ async def _confirm_admin_action(target: CallbackQuery, token: str) -> None:
             )
         with_reason = f"\n原因:{reason}" if reason else ""
         with_suppressed_notification = (
-            "老师申请已被拒绝" if action == "reject" else
-            "老师资格已被撤销" if action == "revoke" else
-            f"老师状态已被管理员修改为“{TEACHER_ACTION_LABELS[action]}”"
+            "技师申请已被拒绝" if action == "reject" else
+            "技师资格已被撤销" if action == "revoke" else
+            f"技师状态已被管理员修改为“{TEACHER_ACTION_LABELS[action]}”"
         )
         try:
             await app.send_message(
@@ -1129,7 +1129,7 @@ async def directory_callback(_, query: CallbackQuery):
             )
         if parts[1] == "clear":
             await clear_user_location(user=query.from_user)
-            await query.message.edit_text("位置已清除,老师状态已同步下线并下榜。")
+            await query.message.edit_text("位置已清除,技师状态已同步下线并下榜。")
             return await query.answer("已清除。")
         if parts[1] == "cancel":
             await query.message.edit_text("已取消。")
@@ -1144,7 +1144,7 @@ async def directory_callback(_, query: CallbackQuery):
             if parts[2] == "approveask":
                 user_id = int(parts[3])
                 return await query.message.edit_text(
-                    "批准后老师身份将在全部群和 Bot 中生效,确认继续吗?",
+                    "批准后技师身份将在全部群和 Bot 中生效,确认继续吗?",
                     reply_markup=InlineKeyboardMarkup(
                         [
                             [
@@ -1169,7 +1169,7 @@ async def directory_callback(_, query: CallbackQuery):
                 try:
                     await app.send_message(
                         int(profile["user_id"]),
-                        "你的老师申请已通过,可以在菜单中自助上榜。",
+                        "你的技师申请已通过,可以在菜单中自助上榜。",
                     )
                 except Exception:
                     pass
@@ -1189,7 +1189,7 @@ async def directory_callback(_, query: CallbackQuery):
     except (DirectoryServiceError, ValueError) as exc:
         return await query.answer(str(exc), show_alert=True)
     except Exception as exc:
-        log.error(f"老师目录回调失败:{exc}")
+        log.error(f"技师目录回调失败:{exc}")
         return await query.answer("操作失败,请稍后重试。", show_alert=True)
 
 
@@ -1213,7 +1213,7 @@ async def directory_private_text(_, message: Message):
     if flow:
         if flow["expires_at"] < datetime.now(UTC):
             _admin_flows.pop(message.from_user.id, None)
-            return await message.reply_text("操作已超时,请重新打开老师审批。")
+            return await message.reply_text("操作已超时,请重新打开技师审批。")
         reason = str(message.text or "").strip()
         if not reason:
             return await message.reply_text("原因不能为空,请重新输入。")
@@ -1262,7 +1262,7 @@ async def directory_private_text(_, message: Message):
         except (DirectoryServiceError, ValueError) as exc:
             return await message.reply_text(f"位置保存失败:{exc}")
         _location_flows.pop(message.from_user.id, None)
-        await message.reply_text("手动位置已更新,可立即查找附近老师。")
+        await message.reply_text("手动位置已更新,可立即查找附近技师。")
         return await _refresh_keyboard(
             message,
             await get_directory_profile(message.from_user.id),
@@ -1278,7 +1278,7 @@ async def directory_private_text(_, message: Message):
         return await _apply_teacher(message)
     if text == BUTTON_STATUS:
         return await _show_status(message)
-    if text == "老师申请审核中":
+    if text == "技师申请审核中":
         return await _show_status(message)
     if text == BUTTON_CLEAR_LOCATION:
         return await message.reply_text(

+ 1677 - 0
wbb/modules/technician_services.py

@@ -0,0 +1,1677 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import suppress
+from datetime import UTC, datetime, timedelta
+from html import escape
+from io import BytesIO
+from typing import Any
+
+import qrcode
+from pyrogram import StopPropagation, filters
+from pyrogram.enums import ParseMode
+from pyrogram.types import (
+    CallbackQuery,
+    InlineKeyboardButton,
+    InlineKeyboardMarkup,
+    KeyboardButton,
+    Message,
+    ReplyKeyboardMarkup,
+)
+
+from wbb import BOT_USERNAME, app, log
+from wbb.utils.dbservice import (
+    ServiceDataError,
+    begin_review_draft,
+    cancel_service_order,
+    claim_review_qr,
+    confirm_service_quote,
+    create_service_order,
+    create_service_report,
+    delete_review_draft,
+    dispute_service_order,
+    get_active_review_template,
+    get_order_for_actor,
+    get_technician_review_summary,
+    get_technician_service_profile,
+    instantiate_package_template,
+    issue_review_qr,
+    list_actor_service_orders,
+    list_available_technicians,
+    list_leaderboard,
+    list_package_templates,
+    list_public_reviews,
+    normalize_quote,
+    observe_customer,
+    preview_review_qr,
+    publish_technician_profile,
+    record_service_funnel_event,
+    redact_expired_addresses,
+    reject_service_order,
+    require_customer_allowed,
+    save_review_draft_progress,
+    set_technician_customer_block,
+    start_service_order,
+    submit_review,
+    submit_service_quote,
+)
+
+__MODULE__ = "技师服务"
+__HELP__ = """/find_technician - 查找附近技师并发起服务请求。
+/my_services - 查看我的服务单。
+/technician_profile - 管理技师服务资料和套餐。
+/review_teacher - 主动评价技师。
+/technician_top - 查看审核评价排行。
+/review_qr - 为服务中的订单生成完成评价二维码。
+/block_customer - 技师拉黑曾发起请求的顾客。
+/report_service - 举报服务单。
+旧 /teacher_top、/teacher_profile 命令继续兼容。"""
+
+ORDER_LABELS = {
+    "requested": "待技师处理",
+    "quoted": "待顾客确认报价",
+    "confirmed": "已确认,待开始服务",
+    "in_progress": "服务中",
+    "completion_pending": "待顾客扫码确认",
+    "completed": "已完成",
+    "rejected": "技师已拒绝",
+    "canceled_customer": "顾客已取消",
+    "canceled_technician": "技师已取消",
+    "disputed": "争议处理中",
+    "voided": "管理员已作废",
+}
+MODE_LABELS = {"at_store": "到店", "onsite": "上门"}
+PRICE_MODE_LABELS = {
+    "fixed": "固定价",
+    "starting_at": "起步价",
+    "range": "价格区间",
+    "negotiable": "面议",
+}
+PRICE_UNIT_LABELS = {
+    "per_service": "次",
+    "per_hour": "小时",
+    "per_item": "件",
+    "per_visit": "次上门",
+}
+TRAVEL_FEE_LABELS = {
+    "included": "已包含",
+    "quoted": "最终报价确认",
+}
+_flows: dict[int, dict[str, Any]] = {}
+_address_redaction_task: asyncio.Task[None] | None = None
+
+
+async def _address_redaction_sweeper() -> None:
+    while True:
+        try:
+            await redact_expired_addresses()
+        except Exception as exc:
+            log.error(f"服务地址脱敏任务失败:{type(exc).__name__}")
+        await asyncio.sleep(3600)
+
+
+def start_address_redaction_sweeper() -> None:
+    global _address_redaction_task
+    if _address_redaction_task and not _address_redaction_task.done():
+        return
+    try:
+        loop = asyncio.get_running_loop()
+        _address_redaction_task = loop.create_task(
+            _address_redaction_sweeper(),
+            name="service-address-redaction-sweeper",
+        )
+    except RuntimeError:
+        _address_redaction_task = None
+
+
+async def stop_address_redaction_sweeper() -> None:
+    global _address_redaction_task
+    if _address_redaction_task:
+        _address_redaction_task.cancel()
+        with suppress(asyncio.CancelledError):
+            await _address_redaction_task
+    _address_redaction_task = None
+
+
+def _flow(user_id: int, stage: str, **values: Any) -> dict[str, Any]:
+    user_id = int(user_id)
+    value = {
+        "stage": stage,
+        "expires_at": datetime.now(UTC) + timedelta(minutes=20),
+        **values,
+    }
+    _flows[user_id] = value
+    try:
+        loop = asyncio.get_running_loop()
+
+        def expire() -> None:
+            if _flows.get(user_id) is value:
+                _flows.pop(user_id, None)
+
+        loop.call_later(20 * 60, expire)
+    except RuntimeError:
+        pass
+    return value
+
+
+def _current_flow(user_id: int) -> dict[str, Any] | None:
+    flow = _flows.get(int(user_id))
+    if flow and flow["expires_at"] >= datetime.now(UTC):
+        return flow
+    _flows.pop(int(user_id), None)
+    return None
+
+
+def _location_keyboard(label: str) -> ReplyKeyboardMarkup:
+    return ReplyKeyboardMarkup(
+        [[KeyboardButton(label, request_location=True)]],
+        resize_keyboard=True,
+        one_time_keyboard=True,
+        selective=True,
+    )
+
+
+def _short(value: Any, limit: int) -> str:
+    text = str(value or "").strip()
+    return text if len(text) <= limit else f"{text[: limit - 1]}…"
+
+
+def _package_price_text(package: dict[str, Any]) -> str:
+    if package.get("price_display"):
+        return str(package["price_display"])
+    mode = package.get("price_mode")
+    currency = package.get("currency") or ""
+    minimum = package.get("min_price") or "0"
+    maximum = package.get("max_price") or minimum
+    unit = PRICE_UNIT_LABELS.get(package.get("price_unit"), "次")
+    if mode == "negotiable":
+        return "面议"
+    if mode == "starting_at":
+        return f"{currency} {minimum} 起/{unit}"
+    if mode == "range":
+        return f"{currency} {minimum}–{maximum}/{unit}"
+    return f"{currency} {minimum}/{unit}"
+
+
+def _travel_fee_text(package: dict[str, Any]) -> str:
+    travel = package.get("travel_fee") or {}
+    mode = travel.get("mode")
+    if mode == "fixed":
+        return f"{package.get('currency', '')} {travel.get('amount') or '0'}"
+    if mode == "per_km":
+        return f"{package.get('currency', '')} {travel.get('per_km') or '0'}/公里"
+    return TRAVEL_FEE_LABELS.get(mode, "最终报价确认")
+
+
+async def _send_message(target: Message | CallbackQuery, text: str, **kwargs: Any):
+    message = target.message if isinstance(target, CallbackQuery) else target
+    return await message.reply_text(text, **kwargs)
+
+
+async def _notify(user_id: int, text: str, **kwargs: Any) -> None:
+    try:
+        await app.send_message(int(user_id), text, **kwargs)
+    except Exception:
+        pass
+
+
+async def _require_terms(target: Message | CallbackQuery) -> bool:
+    if not target.from_user:
+        return False
+    await observe_customer(target.from_user)
+    try:
+        await require_customer_allowed(int(target.from_user.id))
+        return True
+    except ServiceDataError as exc:
+        if exc.code != "terms_required":
+            await _send_message(target, str(exc))
+            return False
+    await _send_message(
+        target,
+        "<b>使用技师服务前请确认</b>\n\n"
+        "平台只记录服务请求、报价、状态与评价,不处理支付和退款。"
+        "时间由双方私聊协商。精确地址仅在报价确认后向履约所需一方开放,"
+        "订单关闭 7 天后自动脱敏。\n\n点击即表示你接受服务规则和隐私说明。",
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(
+            [[InlineKeyboardButton("接受并继续", callback_data="svc:terms")]]
+        ),
+    )
+    return False
+
+
+async def send_service_home(target: Message | CallbackQuery) -> None:
+    if not target.from_user:
+        return
+    if not await _require_terms(target):
+        return
+    await _send_message(
+        target,
+        "<b>技师服务</b>\n选择一个操作。服务请求是主要撮合入口,私聊仅用于确认报价后的时间沟通。",
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(
+            [
+                [InlineKeyboardButton("查找附近技师", callback_data="svc:find")],
+                [
+                    InlineKeyboardButton("我的服务单", callback_data="svc:orders:all:1"),
+                    InlineKeyboardButton("主动评价", callback_data="svc:review_start"),
+                ],
+                [InlineKeyboardButton("审核评价排行", callback_data="svc:top:all:1")],
+            ]
+        ),
+    )
+
+
+async def _ask_search_location(target: Message | CallbackQuery) -> None:
+    if not target.from_user or not await _require_terms(target):
+        return
+    _flow(target.from_user.id, "search_location")
+    await _send_message(
+        target,
+        "请发送当前位置用于计算距离。该位置只保存在当前交互内存中,不会作为精确地址入库。",
+        reply_markup=_location_keyboard("发送当前位置"),
+    )
+
+
+async def _show_nearby(
+    target: Message | CallbackQuery,
+    *,
+    longitude: float,
+    latitude: float,
+    page: int = 1,
+) -> None:
+    if page == 1:
+        await record_service_funnel_event(target.from_user.id, "directory_viewed")
+    items, total = await list_available_technicians(
+        longitude=longitude,
+        latitude=latitude,
+        page=page,
+        page_size=6,
+    )
+    if not items:
+        return await _send_message(target, "附近暂时没有可接单技师。")
+    rows = []
+    lines = [f"<b>附近技师</b>(共 {total} 位)"]
+    for item in items:
+        modes = " / ".join(MODE_LABELS.get(mode, mode) for mode in item["service_modes"])
+        lines.append(
+            f"\n<b>{escape(item.get('display_name') or str(item['user_id']))}</b> · "
+            f"{escape(item['distance_band'])}\n"
+            f"{escape(item.get('public_area_text') or '区域由技师填写')} · {escape(modes)}\n"
+            f"{escape(item.get('headline') or '')}"
+        )
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    f"查看 {item.get('display_name') or item['user_id']}",
+                    callback_data=f"svc:tech:{item['user_id']}",
+                )
+            ]
+        )
+    if page > 1:
+        rows.append([InlineKeyboardButton("上一页", callback_data=f"svc:near:{page - 1}")])
+    if page * 6 < total:
+        rows.append([InlineKeyboardButton("下一页", callback_data=f"svc:near:{page + 1}")])
+    flow = _current_flow(target.from_user.id)
+    if flow is not None:
+        flow.update({"longitude": longitude, "latitude": latitude, "stage": "search_results"})
+    await _send_message(
+        target,
+        "\n".join(lines),
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(rows),
+    )
+
+
+async def _show_technician(target: Message | CallbackQuery, technician_id: int) -> None:
+    if not await _require_terms(target):
+        return
+    await record_service_funnel_event(target.from_user.id, "technician_viewed")
+    profile = await get_technician_service_profile(technician_id)
+    service_profile = profile.get("service_profile") or {}
+    if (
+        profile.get("application_status") != "approved"
+        or not profile.get("listed")
+        or not service_profile.get("is_complete")
+        or not service_profile.get("accepting_requests", True)
+    ):
+        raise ServiceDataError("technician_unavailable", "该技师当前未公开接单。")
+    lines = [
+        f"<b>{escape(profile.get('display_name') or str(technician_id))}</b>",
+        escape(service_profile.get("headline") or ""),
+        escape(_short(service_profile.get("bio"), 300)),
+        f"服务区域:{escape(service_profile.get('public_area_text') or '由技师填写')}",
+        "",
+        "<b>服务套餐</b>",
+    ]
+    review_summary = await get_technician_review_summary(technician_id)
+    lines.insert(
+        4,
+        (
+            f"审核评价:{review_summary['average_score']:.2f} 分 · "
+            f"{review_summary['review_count']} 条"
+            if review_summary["review_count"]
+            else "审核评价:暂无"
+        ),
+    )
+    rows = []
+    for package in service_profile.get("packages", []):
+        modes = "/".join(MODE_LABELS.get(mode, mode) for mode in package["service_modes"])
+        package_lines = [
+            f"\n<b>{escape(package['name'])}</b> · {escape(modes)}\n"
+            f"{escape(_short(package['description'], 100))}\n"
+            f"参考价:{escape(_package_price_text(package))} · "
+            f"约 {package.get('duration_minutes') or '未设'} 分钟",
+            f"交通费:{escape(_travel_fee_text(package))}",
+        ]
+        if "onsite" in package["service_modes"]:
+            package_lines.append(
+                f"上门范围:{package.get('service_radius_km') or '-'} 公里;"
+                f"{escape(_short(package.get('out_of_range_policy'), 60))}"
+            )
+        if package.get("included_items"):
+            package_lines.append(f"包含:{escape(_short(package['included_items'], 50))}")
+        if package.get("excluded_items"):
+            package_lines.append(f"不含:{escape(_short(package['excluded_items'], 40))}")
+        addons = package.get("addons") or []
+        if addons:
+            addon_text = "、".join(
+                f"{item['name']} {package['currency']} {item['price']}/{item['unit']}"
+                for item in addons[:5]
+            )
+            package_lines.append(f"标准加项:{escape(_short(addon_text, 70))}")
+        if package.get("preparation"):
+            package_lines.append(f"准备:{escape(_short(package['preparation'], 40))}")
+        lines.append("\n".join(package_lines))
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    f"发起「{package['name'][:12]}」请求",
+                    callback_data=f"svc:req:{technician_id}:{package['package_id']}",
+                )
+            ]
+        )
+    rows.append(
+        [
+            InlineKeyboardButton(
+                "主动评价该技师", callback_data=f"svc:review:{technician_id}"
+            ),
+            InlineKeyboardButton(
+                "Telegram 私聊(次入口)",
+                url=f"https://t.me/{profile['username']}",
+            ),
+        ]
+    )
+    public_reviews, _ = await list_public_reviews(technician_id, page_size=3)
+    if public_reviews:
+        lines.append("\n<b>最近审核评价</b>")
+        for review in public_reviews:
+            comment = ";".join(review.get("text_answers", {}).values()) or "未填写文字"
+            lines.append(
+                f"{escape(review['source_label'])} · {review['score']:.2f} 分 · "
+                f"{escape(review.get('customer_name') or '匿名顾客')}\n"
+                f"{escape(comment[:120])}"
+            )
+    await _send_message(
+        target,
+        "\n".join(lines),
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(rows),
+    )
+
+
+async def _choose_request_mode(
+    query: CallbackQuery, technician_id: int, package_id: str
+) -> None:
+    profile = await get_technician_service_profile(technician_id)
+    package = next(
+        (
+            item
+            for item in (profile.get("service_profile") or {}).get("packages", [])
+            if item.get("package_id") == package_id
+        ),
+        None,
+    )
+    if not package:
+        raise ServiceDataError("package_not_found", "套餐不存在或已下架。")
+    await record_service_funnel_event(query.from_user.id, "request_started")
+    rows = [
+        [
+            InlineKeyboardButton(
+                MODE_LABELS[mode],
+                callback_data=f"svc:mode:{technician_id}:{package_id}:{mode}",
+            )
+        ]
+        for mode in package["service_modes"]
+    ]
+    await query.message.reply_text(
+        f"已选择「{package['name']}」,请选择服务方式。报价确认前不会展示精确地址。",
+        reply_markup=InlineKeyboardMarkup(rows),
+    )
+
+
+async def _request_mode(
+    query: CallbackQuery,
+    technician_id: int,
+    package_id: str,
+    service_mode: str,
+) -> None:
+    current = _current_flow(query.from_user.id) or {}
+    values = {
+        "technician_id": technician_id,
+        "package_id": package_id,
+        "service_mode": service_mode,
+    }
+    if service_mode == "onsite":
+        _flow(query.from_user.id, "request_location", **values)
+        await query.message.reply_text(
+            "请发送上门位置。精确坐标会单独加密,报价确认前技师只能看到距离区间。",
+            reply_markup=_location_keyboard("发送上门位置"),
+        )
+    else:
+        if "longitude" not in current or "latitude" not in current:
+            raise ServiceDataError(
+                "customer_location_required", "位置已过期,请重新查找附近技师。"
+            )
+        values.update(
+            {
+                "longitude": current["longitude"],
+                "latitude": current["latitude"],
+            }
+        )
+        _flow(query.from_user.id, "request_requirements", **values)
+        await query.message.reply_text("请描述服务需求和补充说明。")
+
+
+async def _preview_request(message: Message, flow: dict[str, Any]) -> None:
+    profile = await get_technician_service_profile(flow["technician_id"])
+    package = next(
+        item
+        for item in profile["service_profile"]["packages"]
+        if item["package_id"] == flow["package_id"]
+    )
+    text = (
+        "<b>确认服务请求</b>\n"
+        f"技师:{escape(profile.get('display_name') or str(profile['user_id']))}\n"
+        f"套餐:{escape(package['name'])}\n"
+        f"方式:{MODE_LABELS[flow['service_mode']]}\n"
+        f"需求:{escape(flow['requirements'])}\n\n"
+        "提交后等待技师拒绝或给出最终报价。"
+    )
+    await message.reply_text(
+        text,
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(
+            [
+                [InlineKeyboardButton("确认提交", callback_data="svc:request_submit")],
+                [InlineKeyboardButton("取消", callback_data="svc:flow_cancel")],
+            ]
+        ),
+    )
+
+
+def _order_text(order: dict[str, Any]) -> str:
+    quote = order.get("quote_snapshot") or {}
+    lines = [
+        f"<b>服务单 {escape(order['order_id'][:8])}</b>",
+        f"状态:{ORDER_LABELS.get(order['status'], order['status'])}",
+        f"技师:{escape(order.get('technician_name') or str(order['technician_id']))}",
+        f"顾客:{escape(order.get('customer_name') or str(order['customer_id']))}",
+        f"套餐:{escape((order.get('package_snapshot') or {}).get('name') or '')}",
+        f"方式:{MODE_LABELS.get(order['service_mode'], order['service_mode'])}",
+        f"距离:{escape(order.get('distance_band') or '')}",
+        f"需求:{escape(order.get('requirements') or '')}",
+    ]
+    if quote:
+        lines.extend(
+            [
+                "",
+                "<b>最终报价</b>",
+                f"服务金额:{quote['currency']} {quote['base_amount']}",
+                f"交通费:{quote['travel_fee']}",
+                f"优惠:{quote['discount']}",
+                f"总价:<b>{quote['currency']} {quote['total_amount']}</b>",
+                f"说明:{escape(quote.get('note') or '无')}",
+            ]
+        )
+    return "\n".join(lines)
+
+
+def _order_markup(order: dict[str, Any], actor_id: int) -> InlineKeyboardMarkup | None:
+    is_customer = actor_id == int(order["customer_id"])
+    is_technician = actor_id == int(order["technician_id"])
+    status = order["status"]
+    rows: list[list[InlineKeyboardButton]] = []
+    if status in {"requested", "quoted"} and is_technician:
+        rows.append(
+            [
+                InlineKeyboardButton("提交最终报价", callback_data=f"svc:quote:{order['order_id']}"),
+                InlineKeyboardButton("拒绝", callback_data=f"svc:reject:{order['order_id']}"),
+            ]
+        )
+    if status == "quoted" and is_customer:
+        rows.append(
+            [InlineKeyboardButton("确认最终报价", callback_data=f"svc:confirm:{order['order_id']}")]
+        )
+    if status == "confirmed" and is_technician:
+        rows.append(
+            [InlineKeyboardButton("开始服务", callback_data=f"svc:start:{order['order_id']}")]
+        )
+    if status == "in_progress" and is_technician:
+        rows.append(
+            [InlineKeyboardButton("完成并生成评价二维码", callback_data=f"svc:finish:{order['order_id']}")]
+        )
+    if status == "completion_pending" and is_technician:
+        rows.append(
+            [InlineKeyboardButton("二维码过期后重新生成", callback_data=f"svc:finish:{order['order_id']}")]
+        )
+    if status in {"confirmed", "in_progress", "completion_pending"}:
+        other_id = order["technician_id"] if is_customer else order["customer_id"]
+        rows.append([InlineKeyboardButton("联系对方协商时间", url=f"tg://user?id={other_id}")])
+    if status in {"requested", "quoted", "confirmed", "in_progress", "completion_pending"} and (
+        is_customer or is_technician
+    ):
+        rows.append(
+            [
+                InlineKeyboardButton("取消服务单", callback_data=f"svc:cancel:{order['order_id']}"),
+                InlineKeyboardButton("发起争议", callback_data=f"svc:dispute:{order['order_id']}"),
+            ]
+        )
+    return InlineKeyboardMarkup(rows) if rows else None
+
+
+async def _show_order(target: Message | CallbackQuery, order_id: str) -> None:
+    order = await get_order_for_actor(order_id, target.from_user.id, reveal_address=True)
+    text = _order_text(order)
+    exact = order.get("exact_address")
+    if exact:
+        text += f"\n\n<b>履约地址</b>\n{escape(exact.get('address_text') or '')}"
+    await _send_message(
+        target,
+        text,
+        parse_mode=ParseMode.HTML,
+        reply_markup=_order_markup(order, target.from_user.id),
+    )
+
+
+async def _show_orders(target: Message | CallbackQuery, *, role: str, page: int) -> None:
+    items, total = await list_actor_service_orders(
+        target.from_user.id, role=role, page=page, page_size=8
+    )
+    if not items:
+        return await _send_message(target, "暂时没有服务单。")
+    rows = [
+        [
+            InlineKeyboardButton(
+                f"{item['order_id'][:8]} · {ORDER_LABELS.get(item['status'], item['status'])}",
+                callback_data=f"svc:order:{item['order_id']}",
+            )
+        ]
+        for item in items
+    ]
+    navigation = []
+    if page > 1:
+        navigation.append(
+            InlineKeyboardButton("上一页", callback_data=f"svc:orders:{role}:{page - 1}")
+        )
+    if page * 8 < total:
+        navigation.append(
+            InlineKeyboardButton("下一页", callback_data=f"svc:orders:{role}:{page + 1}")
+        )
+    if navigation:
+        rows.append(navigation)
+    await _send_message(
+        target,
+        f"<b>我的服务单</b>(共 {total} 单)",
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(rows),
+    )
+
+
+async def _start_quote(target: Message | CallbackQuery, order_id: str) -> None:
+    actor_id = target.from_user.id
+    order = await get_order_for_actor(order_id, actor_id)
+    if actor_id != order["technician_id"] or order["status"] not in {
+        "requested",
+        "quoted",
+    }:
+        raise ServiceDataError("invalid_order_state", "当前服务单不能报价。")
+    _flow(actor_id, "quote_base", order=order, quote={})
+    reference = order["package_snapshot"]
+    await _send_message(
+        target,
+        f"套餐参考价:{reference['currency']} {reference['min_price']} - {reference['max_price']}。\n"
+        "第 1/5 步:请输入本次服务金额。"
+    )
+
+
+def _parse_addons(text: str) -> list[dict[str, Any]]:
+    if text.strip() in {"跳过", "无", "0"}:
+        return []
+    addons = []
+    for raw in text.replace(";", ";").split(";"):
+        values = [item.strip() for item in raw.split("|")]
+        if len(values) != 3:
+            raise ServiceDataError("invalid_addons", "加项格式应为:名称|数量|单价。")
+        addons.append({"name": values[0], "quantity": int(values[1]), "unit_price": values[2]})
+    return addons
+
+
+async def _preview_quote(message: Message, flow: dict[str, Any]) -> None:
+    quote = normalize_quote(flow["quote"], package=flow["order"]["package_snapshot"])
+    flow["normalized_quote"] = quote
+    flow["stage"] = "quote_preview"
+    await message.reply_text(
+        "<b>确认最终报价</b>\n"
+        f"服务金额:{quote['currency']} {quote['base_amount']}\n"
+        f"交通费:{quote['travel_fee']}\n"
+        f"加项数量:{len(quote['addons'])}\n"
+        f"优惠:{quote['discount']}\n"
+        f"总价:<b>{quote['currency']} {quote['total_amount']}</b>\n"
+        f"说明:{escape(quote['note'] or '无')}\n\n"
+        "顾客确认后不能原地改价;如需改价必须取消并重新发起服务单。",
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(
+            [
+                [InlineKeyboardButton("提交报价", callback_data="svc:quote_submit")],
+                [InlineKeyboardButton("取消", callback_data="svc:flow_cancel")],
+            ]
+        ),
+    )
+
+
+async def _issue_qr(target: Message | CallbackQuery, order_id: str) -> None:
+    qr_record, token = await issue_review_qr(order_id, target.from_user.id)
+    deep_link = f"https://t.me/{BOT_USERNAME}?start=review_{token}"
+    image = qrcode.make(deep_link)
+    output = BytesIO()
+    output.name = f"service-{order_id[:8]}-review.png"
+    image.save(output, format="PNG")
+    output.seek(0)
+    await app.send_photo(
+        target.from_user.id,
+        photo=output,
+        caption=(
+            f"服务单 {order_id[:8]} 的完成评价二维码\n"
+            f"仅顾客 {qr_record['customer_id']} 可领取,{qr_record['expires_at']:%Y-%m-%d %H:%M} UTC 前有效。\n\n"
+            f"备用链接:{deep_link}"
+        ),
+    )
+    await _notify(
+        qr_record["customer_id"],
+        f"技师已发起服务完成确认。请扫码或点击备用链接确认:{deep_link}",
+    )
+
+
+async def open_review_qr(message: Message, token: str) -> None:
+    if not message.from_user or not await _require_terms(message):
+        return
+    try:
+        record = await preview_review_qr(token, message.from_user.id)
+    except ServiceDataError as exc:
+        return await message.reply_text(str(exc))
+    if record.get("status") == "claimed":
+        await message.reply_text("服务单已确认完成,继续填写评价。")
+        return await _begin_review(
+            message,
+            technician_id=record["technician_id"],
+            source="qr_verified",
+            qr_id=record["qr_id"],
+        )
+    quote = record.get("quote_snapshot") or {}
+    await message.reply_text(
+        "<b>确认服务完成</b>\n"
+        f"服务单:{escape(record['order_id'][:8])}\n"
+        f"套餐:{escape((record.get('package_snapshot') or {}).get('name') or '')}\n"
+        f"最终报价:{quote.get('currency', '')} {quote.get('total_amount', '')}\n\n"
+        "确认后服务单将进入已完成,并立即打开评价模板。",
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(
+            [[InlineKeyboardButton("确认完成并评价", callback_data=f"svc:claim:{token}")]]
+        ),
+    )
+
+
+async def _begin_review(
+    target: Message | CallbackQuery,
+    *,
+    technician_id: int,
+    source: str,
+    package_id: str | None = None,
+    qr_id: str | None = None,
+) -> None:
+    template = await get_active_review_template()
+    draft = await begin_review_draft(
+        customer_id=target.from_user.id,
+        technician_id=technician_id,
+        source=source,
+        package_id=package_id,
+        qr_id=qr_id,
+        template=template,
+    )
+    _flow(
+        target.from_user.id,
+        "review_rating",
+        technician_id=technician_id,
+        source=source,
+        package_id=package_id,
+        qr_id=qr_id,
+        template=draft["template_snapshot"],
+        rating_index=int(draft.get("rating_index") or 0),
+        text_index=int(draft.get("text_index") or 0),
+        answers=dict(draft.get("answers") or {}),
+    )
+    await _ask_rating(target)
+
+
+async def _ask_rating(target: Message | CallbackQuery) -> None:
+    flow = _current_flow(target.from_user.id)
+    if not flow:
+        return
+    ratings = flow["template"]["rating_questions"]
+    index = flow["rating_index"]
+    if index >= len(ratings):
+        return await _ask_review_text(target)
+    question = ratings[index]
+    await _send_message(
+        target,
+        f"评价 {index + 1}/{len(ratings)}:<b>{escape(question['label'])}</b>\n"
+        f"{escape(question.get('description') or '')}",
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(
+            [[InlineKeyboardButton(f"{score} 分", callback_data=f"svc:rate:{score}") for score in range(1, 6)]]
+        ),
+    )
+
+
+async def _ask_review_text(target: Message | CallbackQuery) -> None:
+    flow = _current_flow(target.from_user.id)
+    if not flow:
+        return
+    texts = flow["template"]["text_questions"]
+    index = flow["text_index"]
+    if index >= len(texts):
+        flow["stage"] = "review_anonymous"
+        return await _send_message(
+            target,
+            "请选择评价展示方式。默认匿名,审核通过后才会公开并计入排行。",
+            reply_markup=InlineKeyboardMarkup(
+                [
+                    [InlineKeyboardButton("匿名发布(默认)", callback_data="svc:anonymous:1")],
+                    [InlineKeyboardButton("显示昵称", callback_data="svc:anonymous:0")],
+                ]
+            ),
+        )
+    question = texts[index]
+    flow["stage"] = "review_text"
+    await _send_message(
+        target,
+        f"文字评价 {index + 1}/{len(texts)}:{question['label']}\n"
+        f"最多 {question['max_length']} 字。"
+        + ("" if question.get("required") else " 可发送“跳过”。"),
+    )
+
+
+async def _submit_pending_review(query: CallbackQuery, anonymous: bool) -> None:
+    flow = _current_flow(query.from_user.id)
+    if not flow or flow["stage"] != "review_anonymous":
+        raise ServiceDataError("review_flow_expired", "评价流程已过期,请重新发起。")
+    review = await submit_review(
+        customer=query.from_user,
+        technician_id=flow["technician_id"],
+        source=flow["source"],
+        answers=flow["answers"],
+        anonymous=anonymous,
+        package_id=flow.get("package_id"),
+        qr_id=flow.get("qr_id"),
+        template_snapshot=flow["template"],
+    )
+    await delete_review_draft(query.from_user.id)
+    _flows.pop(query.from_user.id, None)
+    await query.message.reply_text(
+        f"评价 {review['review_id'][:8]} 已提交审核。审核通过后才会公开并计入审核评价排行。"
+    )
+
+
+async def _choose_review_package(target: Message | CallbackQuery, technician_id: int) -> None:
+    profile = await get_technician_service_profile(technician_id)
+    packages = (profile.get("service_profile") or {}).get("packages", [])
+    if not packages:
+        raise ServiceDataError("package_not_found", "该技师暂无可评价套餐。")
+    await _send_message(
+        target,
+        f"请选择要评价的 {profile.get('display_name') or technician_id} 套餐。主动评价无需服务单,可重复提交,审核通过后同等计分。",
+        reply_markup=InlineKeyboardMarkup(
+            [
+                [
+                    InlineKeyboardButton(
+                        package["name"],
+                        callback_data=f"svc:reviewpkg:{technician_id}:{package['package_id']}",
+                    )
+                ]
+                for package in packages
+            ]
+        ),
+    )
+
+
+async def _show_top(target: Message | CallbackQuery, category: str, page: int) -> None:
+    items, total = await list_leaderboard(
+        category="" if category == "all" else category,
+        page=page,
+        page_size=10,
+    )
+    lines = [
+        "<b>审核评价排行</b>",
+        "主动评价与完成服务单评价审核通过后同等计分;至少 3 条批准评价入榜。",
+        "公式:v/(v+5)×技师平均分 + 5/(v+5)×分类平均分。",
+    ]
+    for index, item in enumerate(items, start=(page - 1) * 10 + 1):
+        lines.append(
+            f"\n{index}. <b>{escape(item.get('display_name') or str(item['technician_id']))}</b>"
+            f" · {escape(item['category'])}\n"
+            f"排行分 {item['rank_score']:.2f} · 平均 {item['average_score']:.2f} · {item['review_count']} 条"
+        )
+    if not items:
+        lines.append("\n暂时没有满足 3 条审核评价门槛的技师。")
+    rows = []
+    if page > 1:
+        rows.append([InlineKeyboardButton("上一页", callback_data=f"svc:top:{category}:{page - 1}")])
+    if page * 10 < total:
+        rows.append([InlineKeyboardButton("下一页", callback_data=f"svc:top:{category}:{page + 1}")])
+    await _send_message(
+        target,
+        "\n".join(lines),
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(rows) if rows else None,
+    )
+
+
+async def _show_profile(target: Message | CallbackQuery) -> None:
+    try:
+        profile = await get_technician_service_profile(target.from_user.id)
+    except ServiceDataError as exc:
+        return await _send_message(target, str(exc))
+    if profile.get("application_status") != "approved":
+        return await _send_message(target, "只有已通过管理员认证的技师可以发布服务资料。")
+    service_profile = profile.get("service_profile") or {}
+    if service_profile.get("is_complete"):
+        package_items = service_profile.get("packages", [])
+        packages = "、".join(item["name"] for item in package_items)
+        rows = [
+            [
+                InlineKeyboardButton(
+                    "暂停接单" if service_profile.get("accepting_requests") else "开始接单",
+                    callback_data="svc:profile_toggle",
+                )
+            ]
+        ]
+        if len(package_items) < 5:
+            rows.append(
+                [InlineKeyboardButton("新增套餐", callback_data="svc:profile_add")]
+            )
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    "重建资料与套餐", callback_data="svc:profile_reconfigure"
+                )
+            ]
+        )
+        return await _send_message(
+            target,
+            "<b>我的技师服务资料</b>\n"
+            f"简介:{escape(service_profile.get('headline') or '')}\n"
+            f"区域:{escape(service_profile.get('public_area_text') or '')}\n"
+            f"接单:{'开启' if service_profile.get('accepting_requests') else '暂停'}\n"
+            f"套餐:{escape(packages)}",
+            parse_mode=ParseMode.HTML,
+            reply_markup=InlineKeyboardMarkup(rows),
+        )
+    await _show_profile_sources(target)
+
+
+async def _show_profile_sources(target: Message | CallbackQuery) -> None:
+    flow = _current_flow(target.from_user.id)
+    if not flow or flow.get("stage") != "profile_source":
+        flow = _flow(target.from_user.id, "profile_source", profile_mode="replace")
+    profile_mode = flow.get("profile_mode", "replace")
+    templates = await list_package_templates()
+    rows = [
+        [
+            InlineKeyboardButton(
+                item["name"], callback_data=f"svc:profile_template:{item['template_id']}"
+            )
+        ]
+        for item in templates
+        if item.get("status") == "enabled"
+    ]
+    rows.append(
+        [InlineKeyboardButton("完全自建套餐", callback_data="svc:profile_custom")]
+    )
+    await _send_message(
+        target,
+        (
+            "选择一个后台套餐模板新增套餐,或完全自建套餐。"
+            if profile_mode == "append"
+            else "选择一个后台套餐模板开始,或完全自建套餐。"
+        )
+        + "模板只用于预填,发布时会保存独立快照;后台后续修改不会覆盖你的套餐。",
+        reply_markup=InlineKeyboardMarkup(rows),
+    )
+
+
+async def _start_profile_flow(query: CallbackQuery, template_id: str) -> None:
+    current = _current_flow(query.from_user.id) or {}
+    package = await instantiate_package_template(template_id)
+    flow = _flow(
+        query.from_user.id,
+        "template_preview",
+        package=package,
+        profile={},
+        profile_mode=current.get("profile_mode", "replace"),
+    )
+    await _show_template_package_preview(query, flow)
+
+
+async def _show_template_package_preview(
+    target: Message | CallbackQuery,
+    flow: dict[str, Any],
+) -> None:
+    package = flow["package"]
+    flow["stage"] = "template_preview"
+    await _send_message(
+        target,
+        "<b>模板套餐预览</b>\n"
+        f"名称:{escape(package['name'])}\n"
+        f"分类:{escape(package['category'])}\n"
+        f"方式:{'/'.join(MODE_LABELS[item] for item in package['service_modes'])}\n"
+        f"价格:{package['currency']} {package['min_price']} - {package['max_price']}\n"
+        f"时长:{package.get('duration_minutes') or '未设'} 分钟\n"
+        f"说明:{escape(package['description'])}\n\n"
+        "可只修改需要调整的部分;未修改字段保留模板快照。",
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(
+            [
+                [InlineKeyboardButton("直接使用并填写资料", callback_data="svc:profile_template_use")],
+                [
+                    InlineKeyboardButton("名称", callback_data="svc:profile_edit:name"),
+                    InlineKeyboardButton("分类", callback_data="svc:profile_edit:category"),
+                    InlineKeyboardButton("说明", callback_data="svc:profile_edit:description"),
+                ],
+                [
+                    InlineKeyboardButton("标签", callback_data="svc:profile_edit:tags"),
+                    InlineKeyboardButton("方式", callback_data="svc:profile_edit:modes"),
+                ],
+                [
+                    InlineKeyboardButton("价格", callback_data="svc:profile_edit:price"),
+                    InlineKeyboardButton("币种/计价", callback_data="svc:profile_edit:billing"),
+                    InlineKeyboardButton("时长/半径", callback_data="svc:profile_edit:duration"),
+                ],
+                [
+                    InlineKeyboardButton("包含与交通", callback_data="svc:profile_edit:details"),
+                    InlineKeyboardButton("标准加项", callback_data="svc:profile_edit:addons"),
+                ],
+            ]
+        ),
+    )
+
+
+async def _start_custom_package_flow(query: CallbackQuery) -> None:
+    current = _current_flow(query.from_user.id) or {}
+    _flow(
+        query.from_user.id,
+        "custom_name",
+        package={},
+        profile={},
+        profile_mode=current.get("profile_mode", "replace"),
+    )
+    await query.message.reply_text("自建套餐 1/9:请输入套餐名称。")
+
+
+async def _publish_profile(
+    target: Message | CallbackQuery, flow: dict[str, Any]
+) -> None:
+    user_id = target.from_user.id
+    if flow.get("profile_mode") == "append":
+        current = await get_technician_service_profile(user_id)
+        current_values = current.get("service_profile") or {}
+        packages = list(current_values.get("packages", []))
+        if len(packages) >= 5:
+            raise ServiceDataError("invalid_package_count", "每位技师最多发布 5 个套餐。")
+        packages.append(flow["package"])
+        values = {
+            "headline": current_values.get("headline"),
+            "bio": current_values.get("bio"),
+            "contact_hours": current_values.get("contact_hours"),
+            "public_area_text": current_values.get("public_area_text"),
+            "tags": current_values.get("tags", []),
+            "venue": current_values.get("venue", {}),
+            "onsite_policy": current_values.get("onsite_policy", {}),
+            "packages": packages,
+            "accepting_requests": current_values.get("accepting_requests", True),
+        }
+    else:
+        profile_values = dict(flow["profile"])
+        values = {
+            "headline": profile_values["headline"],
+            "bio": profile_values["bio"],
+            "contact_hours": profile_values["contact_hours"],
+            "public_area_text": profile_values["public_area_text"],
+            "tags": flow["package"].get("tags", []),
+            "venue": {
+                "name": profile_values["venue_name"],
+                "address_hint": profile_values["venue_address"],
+            },
+            "onsite_policy": {"description": profile_values["onsite_description"]},
+            "packages": [flow["package"]],
+            "accepting_requests": True,
+        }
+    profile = await publish_technician_profile(
+        user_id, values, actor_id=user_id
+    )
+    _flows.pop(user_id, None)
+    await _send_message(
+        target,
+        f"服务资料已发布,共 {len(profile['service_profile']['packages'])} 个套餐。"
+        "套餐已保存模板版本和完整快照。"
+    )
+
+
+@app.on_message(filters.private & filters.command("find_technician"))
+async def find_technician_command(_, message: Message):
+    await _ask_search_location(message)
+
+
+@app.on_message(filters.private & filters.command(["my_services", "my_orders"]))
+async def my_services_command(_, message: Message):
+    if await _require_terms(message):
+        await _show_orders(message, role="all", page=1)
+
+
+@app.on_message(filters.private & filters.command(["technician_top", "teacher_top"]))
+async def technician_top_command(_, message: Message):
+    if not await _require_terms(message):
+        return
+    category = message.command[1] if len(message.command) > 1 else "all"
+    await _show_top(message, category, 1)
+
+
+@app.on_message(filters.private & filters.command(["technician_profile", "teacher_profile"]))
+async def technician_profile_command(_, message: Message):
+    await _show_profile(message)
+
+
+@app.on_message(filters.private & filters.command("review_teacher"))
+async def review_teacher_command(_, message: Message):
+    if not await _require_terms(message):
+        return
+    if len(message.command) < 2 or not message.command[1].isdigit():
+        return await message.reply_text("请使用 /review_teacher 技师ID,或从技师详情点击“主动评价”。")
+    await _choose_review_package(message, int(message.command[1]))
+
+
+@app.on_message(filters.private & filters.command("review_qr"))
+async def review_qr_command(_, message: Message):
+    if len(message.command) < 2:
+        return await message.reply_text("请使用 /review_qr 服务单ID,或在服务单详情点击生成二维码。")
+    try:
+        await _issue_qr(message, message.command[1])
+    except ServiceDataError as exc:
+        await message.reply_text(str(exc))
+
+
+@app.on_message(filters.private & filters.command("block_customer"))
+async def block_customer_command(_, message: Message):
+    if len(message.command) < 3 or not message.command[1].isdigit():
+        return await message.reply_text("请使用 /block_customer 顾客ID 原因。")
+    try:
+        blocked = await set_technician_customer_block(
+            technician_id=message.from_user.id,
+            customer_id=int(message.command[1]),
+            active=True,
+            reason=" ".join(message.command[2:]),
+        )
+        await message.reply_text(f"已拉黑顾客 {blocked['customer_id']},之后的新请求会被拒绝。")
+    except ServiceDataError as exc:
+        await message.reply_text(str(exc))
+
+
+@app.on_message(filters.private & filters.command("report_service"))
+async def report_service_command(_, message: Message):
+    if len(message.command) < 3:
+        return await message.reply_text("请使用 /report_service 服务单ID 举报原因。")
+    order_id = message.command[1]
+    try:
+        await get_order_for_actor(order_id, message.from_user.id)
+        report = await create_service_report(
+            reporter_id=message.from_user.id,
+            target_type="order",
+            target_id=order_id,
+            reason=" ".join(message.command[2:]),
+        )
+        await message.reply_text(f"举报 {report['report_id'][:8]} 已提交后台处理。")
+    except ServiceDataError as exc:
+        await message.reply_text(str(exc))
+
+
+@app.on_message(filters.private & filters.location, group=9)
+async def service_location_message(_, message: Message):
+    if not message.from_user or not message.location:
+        return
+    flow = _current_flow(message.from_user.id)
+    if not flow or flow["stage"] not in {"search_location", "request_location"}:
+        return
+    longitude = message.location.longitude
+    latitude = message.location.latitude
+    if flow["stage"] == "search_location":
+        await _show_nearby(message, longitude=longitude, latitude=latitude)
+    else:
+        flow.update(
+            {
+                "longitude": longitude,
+                "latitude": latitude,
+                "stage": "request_address",
+            }
+        )
+        await message.reply_text("请填写上门精确地址(门牌号/楼层/房间)。该文本会加密保存。")
+    raise StopPropagation
+
+
+@app.on_message(filters.private & filters.command("cancel"), group=-30)
+async def cancel_service_flow(_, message: Message):
+    if not message.from_user or not _flows.pop(message.from_user.id, None):
+        return
+    await message.reply_text("已取消当前技师服务操作。")
+    raise StopPropagation
+
+
+@app.on_message(filters.private & filters.text, group=9)
+async def service_private_text(_, message: Message):
+    if not message.from_user:
+        return
+    flow = _current_flow(message.from_user.id)
+    if not flow:
+        return
+    text = str(message.text or "").strip()
+    stage = flow["stage"]
+    try:
+        if stage == "request_address":
+            if len(text) < 3:
+                return await message.reply_text("请填写完整精确地址。")
+            flow.update({"address_text": text, "stage": "request_requirements"})
+            await message.reply_text("请描述服务需求和补充说明。")
+        elif stage == "request_requirements":
+            flow.update({"requirements": text, "stage": "request_preview"})
+            await _preview_request(message, flow)
+        elif stage == "quote_base":
+            flow["quote"]["base_amount"] = text
+            flow["stage"] = "quote_travel"
+            await message.reply_text("第 2/5 步:请输入交通费;没有则输入 0。")
+        elif stage == "quote_travel":
+            flow["quote"]["travel_fee"] = text
+            flow["stage"] = "quote_addons"
+            await message.reply_text(
+                "第 3/5 步:输入加项“名称|数量|单价”,多个用分号分隔;没有则输入“跳过”。"
+            )
+        elif stage == "quote_addons":
+            flow["quote"]["addons"] = _parse_addons(text)
+            flow["stage"] = "quote_discount"
+            await message.reply_text("第 4/5 步:请输入优惠金额;没有则输入 0。")
+        elif stage == "quote_discount":
+            flow["quote"]["discount"] = text
+            flow["stage"] = "quote_note"
+            await message.reply_text("第 5/5 步:请输入报价说明;没有则输入“无”。")
+        elif stage == "quote_note":
+            flow["quote"]["note"] = "" if text == "无" else text
+            await _preview_quote(message, flow)
+        elif stage in {"cancel_reason", "dispute_reason", "reject_reason"}:
+            order_id = flow["order_id"]
+            if stage == "cancel_reason":
+                order = await cancel_service_order(order_id, message.from_user.id, text)
+            elif stage == "dispute_reason":
+                order = await dispute_service_order(order_id, message.from_user.id, text)
+            else:
+                order = await reject_service_order(order_id, message.from_user.id, text)
+            _flows.pop(message.from_user.id, None)
+            other_id = (
+                order["technician_id"]
+                if message.from_user.id == order["customer_id"]
+                else order["customer_id"]
+            )
+            await message.reply_text(f"服务单已更新为:{ORDER_LABELS[order['status']]}。")
+            await _notify(other_id, f"服务单 {order_id[:8]} 状态已更新:{ORDER_LABELS[order['status']]}。原因:{text}")
+        elif stage == "review_text":
+            question = flow["template"]["text_questions"][flow["text_index"]]
+            if text != "跳过" or question.get("required"):
+                flow["answers"][question["question_id"]] = "" if text == "跳过" else text
+            flow["text_index"] += 1
+            await save_review_draft_progress(
+                message.from_user.id,
+                answers=flow["answers"],
+                rating_index=flow["rating_index"],
+                text_index=flow["text_index"],
+            )
+            await _ask_review_text(message)
+        elif stage == "template_edit_name":
+            flow["package"]["name"] = text
+            flow["package"]["customized_from_template"] = True
+            await _show_template_package_preview(message, flow)
+        elif stage == "template_edit_category":
+            flow["package"]["category"] = text
+            flow["package"]["customized_from_template"] = True
+            await _show_template_package_preview(message, flow)
+        elif stage == "template_edit_description":
+            flow["package"]["description"] = text
+            flow["package"]["customized_from_template"] = True
+            await _show_template_package_preview(message, flow)
+        elif stage == "template_edit_tags":
+            flow["package"]["tags"] = text
+            flow["package"]["customized_from_template"] = True
+            await _show_template_package_preview(message, flow)
+        elif stage == "template_edit_modes":
+            modes = {
+                "到店": ["at_store"],
+                "上门": ["onsite"],
+                "两者": ["at_store", "onsite"],
+            }.get(text)
+            if not modes:
+                return await message.reply_text("请输入“到店”、“上门”或“两者”。")
+            flow["package"]["service_modes"] = modes
+            if "onsite" in modes and not flow["package"].get("service_radius_km"):
+                flow["package"]["service_radius_km"] = 20
+            flow["package"]["customized_from_template"] = True
+            await _show_template_package_preview(message, flow)
+        elif stage == "template_edit_price":
+            values = [item.strip() for item in text.split("|")]
+            if len(values) != 3:
+                return await message.reply_text("格式为:价格模式|最低价|最高价。")
+            flow["package"].update(
+                {
+                    "price_mode": values[0],
+                    "min_price": values[1],
+                    "max_price": values[2],
+                    "customized_from_template": True,
+                }
+            )
+            await _show_template_package_preview(message, flow)
+        elif stage == "template_edit_billing":
+            values = [item.strip() for item in text.split("|")]
+            if len(values) != 2:
+                return await message.reply_text("格式为:ISO币种|计价单位。")
+            flow["package"].update(
+                {
+                    "currency": values[0],
+                    "price_unit": values[1],
+                    "customized_from_template": True,
+                }
+            )
+            await _show_template_package_preview(message, flow)
+        elif stage == "template_edit_duration":
+            values = [item.strip() for item in text.split("|")]
+            if len(values) != 2:
+                return await message.reply_text("格式为:时长分钟|上门半径公里。")
+            flow["package"].update(
+                {
+                    "duration_minutes": int(values[0]),
+                    "service_radius_km": float(values[1]),
+                    "customized_from_template": True,
+                }
+            )
+            await _show_template_package_preview(message, flow)
+        elif stage == "template_edit_details":
+            values = [item.strip() for item in text.split("|")]
+            if len(values) != 7:
+                return await message.reply_text("请按 7 个字段输入,字段内不要使用 |。")
+            flow["package"].update(
+                {
+                    "included_items": values[0],
+                    "excluded_items": values[1],
+                    "preparation": values[2],
+                    "out_of_range_policy": values[3],
+                    "travel_fee": {
+                        "mode": values[4],
+                        "amount": values[5],
+                        "per_km": values[6],
+                    },
+                    "customized_from_template": True,
+                }
+            )
+            await _show_template_package_preview(message, flow)
+        elif stage == "template_edit_addons":
+            addons = []
+            if text not in {"无", "清空", "0"}:
+                for raw in text.replace(";", ";").split(";"):
+                    values = [item.strip() for item in raw.split("|")]
+                    if len(values) != 4:
+                        return await message.reply_text("加项格式为:名称|单位|价格|额外分钟。")
+                    addons.append(
+                        {
+                            "name": values[0],
+                            "unit": values[1],
+                            "price": values[2],
+                            "extra_minutes": int(values[3]),
+                        }
+                    )
+            flow["package"]["addons"] = addons
+            flow["package"]["customized_from_template"] = True
+            await _show_template_package_preview(message, flow)
+        elif stage == "custom_name":
+            flow["package"]["name"] = text
+            flow["stage"] = "custom_category"
+            await message.reply_text("自建套餐 2/9:请输入服务分类。")
+        elif stage == "custom_category":
+            flow["package"]["category"] = text
+            flow["stage"] = "custom_description"
+            await message.reply_text("自建套餐 3/9:请输入套餐详细说明。")
+        elif stage == "custom_description":
+            flow["package"]["description"] = text
+            flow["stage"] = "custom_tags"
+            await message.reply_text("自建套餐 4/9:请输入标签,多个用逗号分隔。")
+        elif stage == "custom_tags":
+            flow["package"]["tags"] = text
+            flow["stage"] = "custom_modes"
+            await message.reply_text("自建套餐 5/9:输入“到店”、“上门”或“两者”。")
+        elif stage == "custom_modes":
+            modes = {
+                "到店": ["at_store"],
+                "上门": ["onsite"],
+                "两者": ["at_store", "onsite"],
+            }.get(text)
+            if not modes:
+                return await message.reply_text("请输入“到店”、“上门”或“两者”。")
+            flow["package"]["service_modes"] = modes
+            flow["stage"] = "custom_price"
+            await message.reply_text(
+                "自建套餐 6/9:按“价格模式|币种|最低价|最高价|计价单位”输入。\n"
+                "价格模式:fixed/starting_at/range/negotiable;计价单位:per_service/per_hour/per_item/per_visit。\n"
+                "示例:fixed|CNY|200|200|per_service"
+            )
+        elif stage == "custom_price":
+            values = [item.strip() for item in text.split("|")]
+            if len(values) != 5:
+                return await message.reply_text("格式不正确,请按 5 个字段重新输入。")
+            price_mode, currency, min_price, max_price, price_unit = values
+            flow["package"].update(
+                {
+                    "price_mode": price_mode,
+                    "currency": currency,
+                    "min_price": min_price,
+                    "max_price": max_price,
+                    "price_unit": price_unit,
+                }
+            )
+            flow["stage"] = "custom_duration"
+            await message.reply_text("自建套餐 7/9:输入“默认时长分钟|上门半径公里”;不支持上门时半径填 0。")
+        elif stage == "custom_duration":
+            values = [item.strip() for item in text.split("|")]
+            if len(values) != 2:
+                return await message.reply_text("格式不正确,例如:60|20。")
+            flow["package"]["duration_minutes"] = int(values[0])
+            flow["package"]["service_radius_km"] = (
+                float(values[1]) if "onsite" in flow["package"]["service_modes"] else None
+            )
+            flow["stage"] = "custom_details"
+            await message.reply_text("自建套餐 8/9:输入“包含项|不包含项|准备事项|超区说明”。")
+        elif stage == "custom_details":
+            values = [item.strip() for item in text.split("|")]
+            if len(values) != 4:
+                return await message.reply_text("格式不正确,请按 4 个字段重新输入。")
+            flow["package"].update(
+                {
+                    "included_items": values[0],
+                    "excluded_items": values[1],
+                    "preparation": values[2],
+                    "out_of_range_policy": values[3],
+                }
+            )
+            flow["stage"] = "custom_travel"
+            await message.reply_text(
+                "自建套餐 9/9:输入“交通费模式|固定金额|每公里金额”。"
+                "模式为 included/fixed/per_km/quoted,例如:quoted|0|0。"
+            )
+        elif stage == "custom_travel":
+            values = [item.strip() for item in text.split("|")]
+            if len(values) != 3:
+                return await message.reply_text("格式不正确,请按 3 个字段重新输入。")
+            flow["package"].update(
+                {
+                    "travel_fee": {
+                        "mode": values[0],
+                        "amount": values[1],
+                        "per_km": values[2],
+                    },
+                    "addons": [],
+                    "source_type": "custom",
+                }
+            )
+            if flow.get("profile_mode") == "append":
+                await _publish_profile(message, flow)
+            else:
+                flow["stage"] = "profile_headline"
+                await message.reply_text("套餐信息已完成。服务资料第 1/7 步:请输入一句服务介绍(最多 80 字)。")
+        elif stage.startswith("profile_"):
+            profile = flow["profile"]
+            next_steps = {
+                "profile_headline": ("headline", "profile_bio", "第 2/7 步:请输入完整服务介绍。"),
+                "profile_bio": ("bio", "profile_hours", "第 3/7 步:请输入可联系时段。"),
+                "profile_hours": ("contact_hours", "profile_area", "第 4/7 步:请输入公开服务区域描述。"),
+                "profile_area": ("public_area_text", "profile_venue", "第 5/7 步:请输入门店/工作地点名称。"),
+                "profile_venue": ("venue_name", "profile_address", "第 6/7 步:请输入到店精确地址。"),
+                "profile_address": ("venue_address", "profile_onsite", "第 7/7 步:请输入上门范围与超区说明。"),
+            }
+            if stage in next_steps:
+                key, next_stage, prompt = next_steps[stage]
+                profile[key] = text
+                flow["stage"] = next_stage
+                await message.reply_text(prompt)
+            elif stage == "profile_onsite":
+                profile["onsite_description"] = text
+                await _publish_profile(message, flow)
+        else:
+            return
+    except (ServiceDataError, ValueError) as exc:
+        await message.reply_text(f"操作未完成:{exc}")
+    raise StopPropagation
+
+
+@app.on_callback_query(filters.regex(r"^svc:"))
+async def service_callback(_, query: CallbackQuery):
+    parts = str(query.data).split(":")
+    try:
+        action = parts[1]
+        if action == "terms":
+            await observe_customer(query.from_user, accepted_terms=True)
+            await query.answer("已接受。")
+            return await send_service_home(query)
+        if action == "home":
+            return await send_service_home(query)
+        if action == "find":
+            return await _ask_search_location(query)
+        if action == "near":
+            flow = _current_flow(query.from_user.id)
+            if not flow or "longitude" not in flow:
+                return await query.answer("位置已过期,请重新发送。", show_alert=True)
+            return await _show_nearby(
+                query,
+                longitude=flow["longitude"],
+                latitude=flow["latitude"],
+                page=int(parts[2]),
+            )
+        if action == "tech":
+            return await _show_technician(query, int(parts[2]))
+        if action == "req":
+            return await _choose_request_mode(query, int(parts[2]), parts[3])
+        if action == "mode":
+            return await _request_mode(query, int(parts[2]), parts[3], parts[4])
+        if action == "request_submit":
+            flow = _current_flow(query.from_user.id)
+            if not flow or flow["stage"] != "request_preview":
+                raise ServiceDataError("request_flow_expired", "服务请求已过期,请重新发起。")
+            order = await create_service_order(customer=query.from_user, **{
+                key: flow.get(key)
+                for key in (
+                    "technician_id",
+                    "package_id",
+                    "service_mode",
+                    "requirements",
+                    "longitude",
+                    "latitude",
+                    "address_text",
+                )
+            })
+            _flows.pop(query.from_user.id, None)
+            await query.message.reply_text(
+                f"服务请求 {order['order_id'][:8]} 已提交,等待技师报价。",
+                reply_markup=_order_markup(order, query.from_user.id),
+            )
+            await _notify(
+                order["technician_id"],
+                f"收到新的服务请求 {order['order_id'][:8]}。",
+                reply_markup=_order_markup(order, order["technician_id"]),
+            )
+            return await query.answer("已提交。")
+        if action == "flow_cancel":
+            _flows.pop(query.from_user.id, None)
+            await query.message.reply_text("已取消当前操作。")
+            return await query.answer()
+        if action == "orders":
+            return await _show_orders(query, role=parts[2], page=int(parts[3]))
+        if action == "order":
+            return await _show_order(query, parts[2])
+        if action == "quote":
+            await _start_quote(query, parts[2])
+            return await query.answer()
+        if action == "quote_submit":
+            flow = _current_flow(query.from_user.id)
+            if not flow or flow["stage"] != "quote_preview":
+                raise ServiceDataError("quote_flow_expired", "报价流程已过期,请重新发起。")
+            order, _ = await submit_service_quote(
+                flow["order"]["order_id"], query.from_user.id, flow["normalized_quote"]
+            )
+            _flows.pop(query.from_user.id, None)
+            await query.message.reply_text("最终报价已提交,等待顾客确认。")
+            await _notify(order["customer_id"], f"服务单 {order['order_id'][:8]} 已收到最终报价。", reply_markup=_order_markup(order, order["customer_id"]))
+            return await query.answer("已提交。")
+        if action in {"cancel", "dispute", "reject"}:
+            stage = {"cancel": "cancel_reason", "dispute": "dispute_reason", "reject": "reject_reason"}[action]
+            _flow(query.from_user.id, stage, order_id=parts[2])
+            await query.message.reply_text("请填写原因。该原因会通知对方并保留在状态轨迹中。")
+            return await query.answer()
+        if action == "confirm":
+            order = await confirm_service_quote(parts[2], query.from_user.id)
+            await query.message.reply_text("最终报价已确认。精确履约地址和双方联系入口现已解锁。")
+            await _notify(order["technician_id"], f"顾客已确认服务单 {order['order_id'][:8]} 的最终报价。", reply_markup=_order_markup(order, order["technician_id"]))
+            return await _show_order(query, order["order_id"])
+        if action == "start":
+            order = await start_service_order(parts[2], query.from_user.id)
+            await _notify(order["customer_id"], f"技师已开始服务单 {order['order_id'][:8]}。")
+            return await _show_order(query, order["order_id"])
+        if action == "finish":
+            await _issue_qr(query, parts[2])
+            return await query.answer("二维码已生成。")
+        if action == "claim":
+            record, _ = await claim_review_qr(parts[2], query.from_user.id)
+            await query.answer("服务已确认完成。")
+            return await _begin_review(
+                query,
+                technician_id=record["technician_id"],
+                source="qr_verified",
+                qr_id=record["qr_id"],
+            )
+        if action in {"review", "review_start"}:
+            if action == "review_start":
+                return await query.message.reply_text("请从技师详情点击“主动评价”,或使用 /review_teacher 技师ID。")
+            return await _choose_review_package(query, int(parts[2]))
+        if action == "reviewpkg":
+            return await _begin_review(
+                query,
+                technician_id=int(parts[2]),
+                source="student_initiated",
+                package_id=parts[3],
+            )
+        if action == "rate":
+            flow = _current_flow(query.from_user.id)
+            if not flow or flow["stage"] != "review_rating":
+                raise ServiceDataError("review_flow_expired", "评价流程已过期。")
+            question = flow["template"]["rating_questions"][flow["rating_index"]]
+            flow["answers"][question["question_id"]] = int(parts[2])
+            flow["rating_index"] += 1
+            await save_review_draft_progress(
+                query.from_user.id,
+                answers=flow["answers"],
+                rating_index=flow["rating_index"],
+                text_index=flow["text_index"],
+            )
+            await query.answer(f"已选择 {parts[2]} 分。")
+            return await _ask_rating(query)
+        if action == "anonymous":
+            await _submit_pending_review(query, parts[2] == "1")
+            return await query.answer("评价已提交审核。")
+        if action == "top":
+            return await _show_top(query, parts[2], int(parts[3]))
+        if action == "profile_template":
+            await _start_profile_flow(query, parts[2])
+            return await query.answer()
+        if action == "profile_template_use":
+            flow = _current_flow(query.from_user.id)
+            if not flow or flow["stage"] != "template_preview":
+                raise ServiceDataError("profile_flow_expired", "套餐配置流程已过期。")
+            if flow.get("profile_mode") == "append":
+                await _publish_profile(query, flow)
+                return await query.answer("套餐已新增。")
+            flow["stage"] = "profile_headline"
+            await query.message.reply_text("服务资料第 1/7 步:请输入一句服务介绍(最多 80 字)。")
+            return await query.answer()
+        if action == "profile_edit":
+            flow = _current_flow(query.from_user.id)
+            if not flow or flow["stage"] != "template_preview":
+                raise ServiceDataError("profile_flow_expired", "套餐配置流程已过期。")
+            field = parts[2]
+            prompts = {
+                "name": "请输入新的套餐名称。",
+                "category": "请输入新的服务分类。",
+                "description": "请输入新的套餐完整说明。",
+                "tags": "请输入套餐标签,多个用逗号分隔。",
+                "modes": "请输入“到店”、“上门”或“两者”。",
+                "price": "输入“价格模式|最低价|最高价”,例如 fixed|200|200。",
+                "billing": "输入“ISO币种|计价单位”,例如 CNY|per_service。",
+                "duration": "输入“时长分钟|上门半径公里”,例如 60|20。",
+                "details": "输入“包含项|不包含项|准备事项|超区说明|交通模式|固定金额|每公里金额”。",
+                "addons": "输入“名称|单位|价格|额外分钟”,多个用分号分隔;输入“清空”可移除全部。",
+            }
+            if field not in prompts:
+                raise ServiceDataError("invalid_profile_field", "不支持该套餐字段。")
+            flow["stage"] = f"template_edit_{field}"
+            await query.message.reply_text(prompts[field])
+            return await query.answer()
+        if action == "profile_custom":
+            await _start_custom_package_flow(query)
+            return await query.answer()
+        if action == "profile_add":
+            profile = await get_technician_service_profile(query.from_user.id)
+            if len((profile.get("service_profile") or {}).get("packages", [])) >= 5:
+                raise ServiceDataError("invalid_package_count", "每位技师最多发布 5 个套餐。")
+            _flow(query.from_user.id, "profile_source", profile_mode="append")
+            await _show_profile_sources(query)
+            return await query.answer()
+        if action == "profile_reconfigure":
+            _flow(query.from_user.id, "profile_source", profile_mode="replace")
+            await _show_profile_sources(query)
+            return await query.answer()
+        if action == "profile_toggle":
+            profile = await get_technician_service_profile(query.from_user.id)
+            values = dict(profile["service_profile"])
+            values["accepting_requests"] = not values.get("accepting_requests", True)
+            await publish_technician_profile(
+                query.from_user.id, values, actor_id=query.from_user.id
+            )
+            await query.answer("接单状态已更新。")
+            return await _show_profile(query)
+    except (ServiceDataError, ValueError, IndexError) as exc:
+        return await query.answer(str(exc), show_alert=True)
+    except Exception as exc:
+        log.error(f"技师服务回调失败:{type(exc).__name__}")
+        return await query.answer("操作失败,请稍后重试。", show_alert=True)
+
+
+start_address_redaction_sweeper()

+ 5 - 4
wbb/services/bot_permissions.py

@@ -39,9 +39,9 @@ PERMISSIONS = (
     BotPermission("karma.manage", "声望", "社区运营", "处理点赞、点踩和声望排行。"),
     BotPermission(
         "teacher_directory.manage",
-        "老师与附近",
+        "技师与附近",
         "社区运营",
-        "提供老师申请、上下榜、在线状态、位置和附近老师目录。",
+        "提供技师申请、上下榜、在线状态、位置和附近技师目录。",
     ),
     BotPermission("media.upload", "媒体中转", "系统能力", "向媒体中转群上传文件。"),
 )
@@ -108,8 +108,8 @@ BUILTIN_ROLES = (
     ),
     BuiltinBotRole(
         "teacher_directory_manager",
-        "老师目录专员",
-        "负责老师申请、位置、上下榜、在线状态和附近老师目录。",
+        "技师目录专员",
+        "负责技师申请、位置、上下榜、在线状态和附近技师目录。",
         ("teacher_directory.manage",),
     ),
 )
@@ -132,6 +132,7 @@ MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
     "points": frozenset({"points.manage"}),
     "rules": frozenset({"chat.rules"}),
     "teacher_directory": frozenset({"teacher_directory.manage"}),
+    "technician_services": frozenset({"teacher_directory.manage"}),
 }
 
 TELEGRAM_COMMAND_PERMISSIONS = {

+ 6 - 6
wbb/services/directory.py

@@ -281,7 +281,7 @@ async def apply_as_teacher(
     if not application_chat_id:
         raise DirectoryServiceError(
             "application_chat_required",
-            "请先在要申请老师的群里打开附近老师菜单。",
+            "请先在要申请技师的群里打开附近技师菜单。",
             status=409,
         )
     application_chat_title = str(chat_title or "").strip()
@@ -319,7 +319,7 @@ async def change_own_teacher_state(
             actor_id=int(user.id),
             actor_name=identity["display_name"],
             source=source,
-            reason="老师自助操作",
+            reason="技师自助操作",
             idempotency_key=idempotency_key,
             chat_id=chat_id,
         )
@@ -346,7 +346,7 @@ async def admin_source_chat_ids(actor: Any) -> list[int] | None:
     if not values:
         raise DirectoryServiceError(
             "managed_group_admin_required",
-            "只有申请来源群的管理员可以审批老师。",
+            "只有申请来源群的管理员可以审批技师。",
             status=403,
         )
     return values
@@ -366,7 +366,7 @@ async def require_teacher_admin(actor: Any, user_id: int) -> dict[str, Any]:
     if source_bot_id and str(source_bot_id) != str(BOT_PROFILE_ID):
         raise DirectoryServiceError(
             "source_bot_required",
-            "请使用接收老师申请提醒的机器人完成审批。",
+            "请使用接收技师申请提醒的机器人完成审批。",
             status=403,
         )
     try:
@@ -447,7 +447,7 @@ async def directory_for_user(
     if not location:
         raise DirectoryServiceError(
             "location_required",
-            "请先使用 Telegram 定位或手动选择位置,再查看老师。",
+            "请先使用 Telegram 定位或手动选择位置,再查看技师。",
             status=409,
         )
     try:
@@ -502,7 +502,7 @@ async def _presence_sweeper() -> None:
         try:
             await expire_teacher_presence()
         except Exception as exc:
-            log.error(f"老师在线状态过期任务失败:{exc}")
+            log.error(f"技师在线状态过期任务失败:{exc}")
         await asyncio.sleep(60)
 
 

+ 9 - 9
wbb/utils/dbdirectory.py

@@ -265,7 +265,7 @@ async def upsert_directory_identity(
             actor_id=int(user_id),
             actor_name=identity["display_name"],
             source=source,
-            reason="老师移除了 Telegram 用户名,系统自动下榜并下线。",
+            reason="技师移除了 Telegram 用户名,系统自动下榜并下线。",
         )
     return await get_directory_profile(int(user_id)) or {}
 
@@ -402,15 +402,15 @@ async def submit_teacher_application(
     location = await get_directory_location(int(user_id))
     if not profile or not profile.get("username"):
         raise DirectoryDataError(
-            "username_required", "申请老师前必须先设置 Telegram 用户名。"
+            "username_required", "申请技师前必须先设置 Telegram 用户名。"
         )
     if not location:
-        raise DirectoryDataError("location_required", "申请老师前必须先更新位置。")
+        raise DirectoryDataError("location_required", "申请技师前必须先更新位置。")
     if profile.get("application_status") == APPLICATION_APPROVED:
-        raise DirectoryDataError("already_teacher", "你已经是老师,无需重复申请。")
+        raise DirectoryDataError("already_teacher", "你已经是技师,无需重复申请。")
     if profile.get("application_status") == APPLICATION_REVOKED:
         raise DirectoryDataError(
-            "teacher_revoked", "老师资格已被撤销,只能由管理员重新批准。"
+            "teacher_revoked", "技师资格已被撤销,只能由管理员重新批准。"
         )
     if profile.get("application_status") == APPLICATION_PENDING:
         return profile, False
@@ -437,7 +437,7 @@ async def submit_teacher_application(
         actor_id=int(user_id),
         actor_name=str(profile.get("display_name") or user_id),
         source=source,
-        reason="申请成为老师",
+        reason="申请成为技师",
         bot_id=bot_id,
         chat_id=int(chat_id),
     )
@@ -520,7 +520,7 @@ async def set_teacher_state(
     await ensure_directory_indexes()
     profile = await get_directory_profile(int(user_id))
     if not profile or profile.get("application_status") != APPLICATION_APPROVED:
-        raise DirectoryDataError("teacher_required", "该成员尚未取得老师资格。")
+        raise DirectoryDataError("teacher_required", "该成员尚未取得技师资格。")
     location = await get_directory_location(int(user_id))
     if action in {"list", "online"}:
         if not profile.get("username"):
@@ -530,14 +530,14 @@ async def set_teacher_state(
     if action == "online" and not profile.get("listed"):
         raise DirectoryDataError("listing_required", "请先上榜,再切换为上线状态。")
     if action not in {"list", "unlist", "online", "offline"}:
-        raise DirectoryDataError("invalid_teacher_action", "不支持的老师状态操作。")
+        raise DirectoryDataError("invalid_teacher_action", "不支持的技师状态操作。")
     event = await record_directory_event(
         event_type=f"teacher_{action}",
         user_id=int(user_id),
         actor_id=actor_id,
         actor_name=actor_name,
         source=source,
-        reason=reason or f"老师{action}",
+        reason=reason or f"技师{action}",
         idempotency_key=idempotency_key,
         chat_id=chat_id,
     )

+ 1876 - 0
wbb/utils/dbservice.py

@@ -0,0 +1,1876 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import re
+from collections import defaultdict
+from datetime import UTC, datetime, timedelta
+from decimal import Decimal, InvalidOperation
+from secrets import token_urlsafe
+from typing import Any
+from uuid import uuid4
+
+from cryptography.fernet import Fernet, InvalidToken
+from pymongo import ASCENDING, DESCENDING, ReturnDocument
+from pymongo.errors import DuplicateKeyError
+
+import wbb
+from wbb import control_db
+
+templatesdb = control_db.service_package_templates
+ordersdb = control_db.service_orders
+quotesdb = control_db.service_order_quotes
+addressesdb = control_db.service_order_addresses
+eventsdb = control_db.service_order_events
+customersdb = control_db.customer_profiles
+blocksdb = control_db.customer_blocks
+reportsdb = control_db.service_reports
+qrdb = control_db.teacher_review_qr_records
+reviewtemplatesdb = control_db.teacher_review_templates
+reviewdraftsdb = control_db.teacher_review_drafts
+reviewsdb = control_db.teacher_reviews
+statsdb = control_db.teacher_review_stats
+profilesdb = control_db.directory_profiles
+locationsdb = control_db.directory_locations
+membershipsdb = control_db.directory_memberships
+settingsdb = control_db.directory_settings
+
+SERVICE_MODES = {"at_store", "onsite"}
+PRICE_MODES = {"fixed", "starting_at", "range", "negotiable"}
+PRICE_UNITS = {"per_service", "per_hour", "per_item", "per_visit"}
+TRAVEL_FEE_MODES = {"included", "fixed", "per_km", "quoted"}
+ORDER_ACTIVE_STATUSES = {
+    "requested",
+    "quoted",
+    "confirmed",
+    "in_progress",
+    "completion_pending",
+    "disputed",
+}
+ORDER_TERMINAL_STATUSES = {
+    "completed",
+    "rejected",
+    "canceled_customer",
+    "canceled_technician",
+    "voided",
+}
+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": [
+        {
+            "question_id": "service_effect",
+            "label": "服务效果",
+            "description": "本次服务是否达到预期。",
+            "required": True,
+            "weight": 40,
+        },
+        {
+            "question_id": "professionalism",
+            "label": "专业程度",
+            "description": "技师的专业能力与服务规范。",
+            "required": True,
+            "weight": 30,
+        },
+        {
+            "question_id": "communication",
+            "label": "沟通体验",
+            "description": "沟通是否清晰、友好。",
+            "required": True,
+            "weight": 30,
+        },
+    ],
+    "text_questions": [
+        {
+            "question_id": "comment",
+            "label": "评价内容",
+            "description": "分享真实体验,避免包含隐私信息。",
+            "required": False,
+            "max_length": 500,
+        }
+    ],
+}
+
+_indexes_ready = False
+
+
+class ServiceDataError(ValueError):
+    def __init__(self, code: str, message: str):
+        super().__init__(message)
+        self.code = code
+
+
+def utc_now() -> datetime:
+    return datetime.now(UTC)
+
+
+def _as_utc(value: datetime) -> datetime:
+    return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
+
+
+def _clean_text(value: Any, *, max_length: int, required: bool = False) -> str:
+    text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", str(value or ""))
+    text = " ".join(text.strip().split())
+    if required and not text:
+        raise ServiceDataError("required_field", "必填内容不能为空。")
+    if len(text) > max_length:
+        raise ServiceDataError("text_too_long", f"内容不能超过 {max_length} 个字符。")
+    return text
+
+
+def _positive_decimal(value: Any, field: str, *, allow_zero: bool = True) -> str:
+    try:
+        number = Decimal(str(value or 0)).quantize(Decimal("0.01"))
+    except (InvalidOperation, ValueError) as exc:
+        raise ServiceDataError("invalid_price", f"{field} 必须是有效金额。") from exc
+    if not number.is_finite():
+        raise ServiceDataError("invalid_price", f"{field} 必须是有效金额。")
+    if number < 0 or (not allow_zero and number == 0):
+        raise ServiceDataError("invalid_price", f"{field} 不能小于 0。")
+    return format(number, "f")
+
+
+def _normalize_tags(values: Any) -> list[str]:
+    if isinstance(values, str):
+        values = values.replace(",", ",").split(",")
+    tags = []
+    for value in values or []:
+        tag = _clean_text(value, max_length=20)
+        if tag and tag.casefold() not in {item.casefold() for item in tags}:
+            tags.append(tag)
+    if len(tags) > 8:
+        raise ServiceDataError("too_many_tags", "服务标签最多 8 个。")
+    return tags
+
+
+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", "套餐必须选择到店或上门服务方式。")
+    price_mode = str(value.get("price_mode") or "negotiable")
+    if price_mode not in PRICE_MODES:
+        raise ServiceDataError("invalid_price_mode", "不支持该价格模式。")
+    price_unit = str(value.get("price_unit") or "per_service")
+    if price_unit not in PRICE_UNITS:
+        raise ServiceDataError("invalid_price_unit", "不支持该计价单位。")
+    currency = str(value.get("currency") or "CNY").upper()
+    if not re.fullmatch(r"[A-Z]{3}", currency):
+        raise ServiceDataError("invalid_currency", "币种必须使用三位 ISO 代码。")
+    min_price = _positive_decimal(value.get("min_price"), "最低价格")
+    max_price = _positive_decimal(value.get("max_price"), "最高价格")
+    if Decimal(max_price) and Decimal(max_price) < Decimal(min_price):
+        raise ServiceDataError("invalid_price_range", "最高价格不能低于最低价格。")
+    if price_mode == "fixed" and Decimal(min_price) != Decimal(max_price):
+        raise ServiceDataError("invalid_fixed_price", "固定价的最低价格和最高价格必须一致。")
+    duration = value.get("duration_minutes")
+    if duration in (None, "", 0, "0"):
+        duration = None
+    else:
+        try:
+            duration = int(duration)
+        except (TypeError, ValueError) as exc:
+            raise ServiceDataError("invalid_duration", "服务时长必须是分钟数。") from exc
+        if not 15 <= duration <= 480:
+            raise ServiceDataError("invalid_duration", "服务时长必须在 15 到 480 分钟之间。")
+    travel = value.get("travel_fee") if isinstance(value.get("travel_fee"), dict) else {}
+    travel_mode = str(travel.get("mode") or "quoted")
+    if travel_mode not in TRAVEL_FEE_MODES:
+        raise ServiceDataError("invalid_travel_fee", "不支持该交通费模式。")
+    addons = []
+    for item in value.get("addons", []) or []:
+        if len(addons) >= 10:
+            raise ServiceDataError("too_many_addons", "标准加项最多 10 个。")
+        addons.append(
+            {
+                "addon_id": str(item.get("addon_id") or uuid4().hex),
+                "name": _clean_text(item.get("name"), max_length=40, required=True),
+                "unit": _clean_text(item.get("unit"), max_length=20) or "项",
+                "price": _positive_decimal(item.get("price"), "加项价格"),
+                "extra_minutes": max(0, min(480, int(item.get("extra_minutes") or 0))),
+            }
+        )
+    radius = value.get("service_radius_km")
+    if radius in (None, ""):
+        radius = None
+    else:
+        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"}:
+        raise ServiceDataError("invalid_package_source", "套餐来源无效。")
+    return {
+        "package_id": str(package_id or value.get("package_id") or uuid4().hex),
+        "name": _clean_text(value.get("name"), max_length=50, required=True),
+        "category": _clean_text(value.get("category"), max_length=30, required=True),
+        "description": _clean_text(value.get("description"), max_length=800, required=True),
+        "tags": _normalize_tags(value.get("tags", [])),
+        "service_modes": modes,
+        "price_mode": price_mode,
+        "currency": currency,
+        "price_unit": price_unit,
+        "min_price": min_price,
+        "max_price": max_price,
+        "price_display": _clean_text(value.get("price_display"), max_length=80),
+        "duration_minutes": duration,
+        "included_items": _clean_text(value.get("included_items"), max_length=500),
+        "excluded_items": _clean_text(value.get("excluded_items"), max_length=500),
+        "preparation": _clean_text(value.get("preparation"), max_length=500),
+        "addons": addons,
+        "travel_fee": {
+            "mode": travel_mode,
+            "amount": _positive_decimal(travel.get("amount"), "交通费"),
+            "per_km": _positive_decimal(travel.get("per_km"), "每公里交通费"),
+            "description": _clean_text(travel.get("description"), max_length=200),
+        },
+        "service_radius_km": radius,
+        "out_of_range_policy": out_of_range_policy,
+        "source_type": source_type,
+        "source_template_id": value.get("source_template_id"),
+        "source_template_version": value.get("source_template_version"),
+        "customized_from_template": bool(value.get("customized_from_template")),
+    }
+
+
+def _normalize_profile(value: dict[str, Any]) -> dict[str, Any]:
+    packages = [normalize_package(item) for item in value.get("packages", [])]
+    if not 1 <= len(packages) <= 5:
+        raise ServiceDataError("invalid_package_count", "技师必须发布 1 到 5 个套餐。")
+    modes = sorted({mode for item in packages for mode in item["service_modes"]})
+    venue = value.get("venue") if isinstance(value.get("venue"), dict) else {}
+    onsite = value.get("onsite_policy") if isinstance(value.get("onsite_policy"), dict) else {}
+    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
+    )
+    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),
+        "service_modes": modes,
+        "accepting_requests": bool(value.get("accepting_requests", True)),
+        "public_area_text": public_area_text,
+        "venue": {
+            "name": venue_name,
+            "address_hint": venue_address,
+        },
+        "onsite_policy": {
+            "description": onsite_description,
+        },
+        "packages": packages,
+        "is_complete": True,
+    }
+
+
+async def ensure_service_indexes() -> None:
+    global _indexes_ready
+    if _indexes_ready:
+        return
+    await templatesdb.create_index([("template_id", ASCENDING)], unique=True)
+    await templatesdb.create_index([("status", ASCENDING), ("sort_order", ASCENDING)])
+    await ordersdb.create_index([("order_id", ASCENDING)], unique=True)
+    await ordersdb.create_index([("customer_id", ASCENDING), ("created_at", DESCENDING)])
+    await ordersdb.create_index([("technician_id", ASCENDING), ("created_at", DESCENDING)])
+    await ordersdb.create_index([("status", ASCENDING), ("updated_at", DESCENDING)])
+    await quotesdb.create_index([("quote_id", ASCENDING)], unique=True)
+    await quotesdb.create_index([("order_id", ASCENDING), ("version", DESCENDING)], unique=True)
+    await addressesdb.create_index([("order_id", ASCENDING)], unique=True)
+    await addressesdb.create_index([("redact_after", ASCENDING)])
+    await eventsdb.create_index([("event_id", ASCENDING)], unique=True)
+    await eventsdb.create_index([("order_id", ASCENDING), ("created_at", ASCENDING)])
+    await customersdb.create_index([("user_id", ASCENDING)], unique=True)
+    await blocksdb.create_index([("scope", ASCENDING), ("technician_id", ASCENDING), ("customer_id", ASCENDING)], unique=True)
+    await reportsdb.create_index([("report_id", ASCENDING)], unique=True)
+    await qrdb.create_index([("qr_id", ASCENDING)], unique=True)
+    await qrdb.create_index([("token_hash", ASCENDING)], unique=True)
+    await qrdb.create_index([("order_id", ASCENDING), ("created_at", DESCENDING)])
+    await reviewtemplatesdb.create_index([("version", DESCENDING)], unique=True)
+    await reviewdraftsdb.create_index([("customer_id", ASCENDING)], unique=True)
+    await reviewdraftsdb.create_index([("expires_at", ASCENDING)], expireAfterSeconds=0)
+    await reviewsdb.create_index([("review_id", ASCENDING)], unique=True)
+    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)
+    _indexes_ready = True
+
+
+async def record_service_event(
+    order_id: str,
+    event_type: str,
+    *,
+    actor_id: int | str,
+    reason: str = "",
+    metadata: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    await ensure_service_indexes()
+    document = {
+        "event_id": uuid4().hex,
+        "order_id": str(order_id),
+        "event_type": str(event_type),
+        "actor_id": actor_id,
+        "reason": _clean_text(reason, max_length=500),
+        "metadata": metadata or {},
+        "created_at": utc_now(),
+    }
+    await eventsdb.insert_one(document)
+    return document
+
+
+async def record_service_funnel_event(user_id: int, event_type: str) -> dict[str, Any]:
+    if event_type not in {"directory_viewed", "technician_viewed", "request_started"}:
+        raise ServiceDataError("invalid_funnel_event", "不支持该履约漏斗事件。")
+    return await record_service_event(
+        "",
+        event_type,
+        actor_id=int(user_id),
+        metadata={"funnel": True},
+    )
+
+
+async def observe_customer(user: Any, *, accepted_terms: bool = False) -> dict[str, Any]:
+    await ensure_service_indexes()
+    now = utc_now()
+    display_name = " ".join(
+        value for value in (str(getattr(user, "first_name", "") or "").strip(), str(getattr(user, "last_name", "") or "").strip()) if value
+    ) or f"用户 {user.id}"
+    updates: dict[str, Any] = {
+        "username": getattr(user, "username", None),
+        "display_name": display_name,
+        "updated_at": now,
+    }
+    if accepted_terms:
+        updates["terms_accepted_at"] = now
+    await customersdb.update_one(
+        {"user_id": int(user.id)},
+        {"$set": updates, "$setOnInsert": {"created_at": now, "blocked": False}},
+        upsert=True,
+    )
+    return await customersdb.find_one({"user_id": int(user.id)}) or {}
+
+
+async def require_customer_allowed(user_id: int) -> dict[str, Any]:
+    await ensure_service_indexes()
+    customer = await customersdb.find_one({"user_id": int(user_id)})
+    if not customer or not customer.get("terms_accepted_at"):
+        raise ServiceDataError("terms_required", "请先接受服务规则和隐私说明。")
+    if customer.get("blocked"):
+        raise ServiceDataError("customer_blocked", "你的服务功能已被暂停。")
+    global_block = await blocksdb.find_one({"scope": "global", "customer_id": int(user_id), "active": True})
+    if global_block:
+        raise ServiceDataError("customer_blocked", "你的服务功能已被暂停。")
+    return customer
+
+
+async def get_service_settings() -> dict[str, int]:
+    stored = await settingsdb.find_one({"settings_id": "global"}) or {}
+    return {
+        "customer_max_open_orders": max(
+            1,
+            min(
+                20,
+                int(
+                    stored.get("service_customer_max_open_orders")
+                    or getattr(wbb, "SERVICE_CUSTOMER_MAX_OPEN_ORDERS", 3)
+                    or 3
+                ),
+            ),
+        ),
+        "customer_daily_request_limit": max(
+            1,
+            min(
+                100,
+                int(
+                    stored.get("service_customer_daily_request_limit")
+                    or getattr(wbb, "SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT", 10)
+                    or 10
+                ),
+            ),
+        ),
+        "review_qr_expiry_hours": max(
+            1,
+            min(
+                168,
+                int(
+                    stored.get("service_review_qr_expiry_hours")
+                    or getattr(wbb, "SERVICE_REVIEW_QR_EXPIRY_HOURS", 24)
+                    or 24
+                ),
+            ),
+        ),
+    }
+
+
+async def set_service_settings(values: dict[str, Any]) -> dict[str, int]:
+    current = await get_service_settings()
+    try:
+        normalized = {
+            "customer_max_open_orders": max(
+                1,
+                min(20, int(values.get("customer_max_open_orders", current["customer_max_open_orders"]))),
+            ),
+            "customer_daily_request_limit": max(
+                1,
+                min(
+                    100,
+                    int(
+                        values.get(
+                            "customer_daily_request_limit",
+                            current["customer_daily_request_limit"],
+                        )
+                    ),
+                ),
+            ),
+            "review_qr_expiry_hours": max(
+                1,
+                min(168, int(values.get("review_qr_expiry_hours", current["review_qr_expiry_hours"]))),
+            ),
+        }
+    except (TypeError, ValueError) as exc:
+        raise ServiceDataError("invalid_service_settings", "履约设置必须是有效整数。") from exc
+    await settingsdb.update_one(
+        {"settings_id": "global"},
+        {
+            "$set": {
+                "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"],
+                "updated_at": utc_now(),
+            },
+            "$setOnInsert": {"created_at": utc_now()},
+        },
+        upsert=True,
+    )
+    return normalized
+
+
+async def list_package_templates(*, include_archived: bool = False) -> list[dict[str, Any]]:
+    await ensure_service_indexes()
+    filters = {} if include_archived else {"status": {"$ne": "archived"}}
+    return await templatesdb.find(filters).sort([("sort_order", ASCENDING), ("created_at", ASCENDING)]).to_list(length=500)
+
+
+async def create_package_template(values: dict[str, Any], *, actor_id: str) -> dict[str, Any]:
+    await ensure_service_indexes()
+    now = utc_now()
+    package = normalize_package(values.get("package") or values)
+    package["source_type"] = "template"
+    document = {
+        "template_id": uuid4().hex,
+        "version": 1,
+        "name": _clean_text(values.get("template_name") or package["name"], max_length=80, required=True),
+        "admin_note": _clean_text(values.get("admin_note"), max_length=500),
+        "package": package,
+        "status": "enabled" if values.get("enabled", True) else "disabled",
+        "sort_order": int(values.get("sort_order") or 0),
+        "created_by": str(actor_id),
+        "created_at": now,
+        "updated_at": now,
+    }
+    await templatesdb.insert_one(document)
+    return document
+
+
+async def update_package_template(template_id: str, values: dict[str, Any], *, actor_id: str) -> dict[str, Any]:
+    current = await templatesdb.find_one({"template_id": str(template_id)})
+    if not current:
+        raise ServiceDataError("template_not_found", "未找到套餐模板。")
+    package = normalize_package(values.get("package") or values, package_id=current["package"]["package_id"])
+    package["source_type"] = "template"
+    updated = await templatesdb.find_one_and_update(
+        {"template_id": str(template_id)},
+        {
+            "$set": {
+                "name": _clean_text(values.get("template_name") or package["name"], max_length=80, required=True),
+                "admin_note": _clean_text(values.get("admin_note"), max_length=500),
+                "package": package,
+                "sort_order": int(values.get("sort_order") or 0),
+                "updated_by": str(actor_id),
+                "updated_at": utc_now(),
+            },
+            "$inc": {"version": 1},
+        },
+        return_document=ReturnDocument.AFTER,
+    )
+    return updated or {}
+
+
+async def set_package_template_status(template_id: str, action: str, *, actor_id: str) -> dict[str, Any]:
+    status_map = {"enable": "enabled", "disable": "disabled", "archive": "archived"}
+    if action not in status_map:
+        raise ServiceDataError("invalid_template_action", "不支持该模板操作。")
+    updated = await templatesdb.find_one_and_update(
+        {"template_id": str(template_id)},
+        {"$set": {"status": status_map[action], "updated_by": str(actor_id), "updated_at": utc_now()}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not updated:
+        raise ServiceDataError("template_not_found", "未找到套餐模板。")
+    return updated
+
+
+async def duplicate_package_template(template_id: str, *, actor_id: str) -> dict[str, Any]:
+    current = await templatesdb.find_one({"template_id": str(template_id)})
+    if not current:
+        raise ServiceDataError("template_not_found", "未找到套餐模板。")
+    values = {
+        "template_name": f"{current['name']}(副本)",
+        "admin_note": current.get("admin_note", ""),
+        "sort_order": current.get("sort_order", 0),
+        "enabled": False,
+        "package": current["package"],
+    }
+    return await create_package_template(values, actor_id=actor_id)
+
+
+async def publish_technician_profile(user_id: int, values: dict[str, Any], *, actor_id: int | str) -> dict[str, Any]:
+    await ensure_service_indexes()
+    profile = await profilesdb.find_one({"user_id": int(user_id)})
+    if not profile or profile.get("application_status") != "approved":
+        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)
+    now = utc_now()
+    normalized.update({"updated_at": now, "updated_by": actor_id, "version": int((profile.get("service_profile") or {}).get("version") or 0) + 1})
+    await profilesdb.update_one(
+        {"user_id": int(user_id)},
+        {"$set": {"service_profile": normalized, "updated_at": now}},
+    )
+    return await profilesdb.find_one({"user_id": int(user_id)}) or {}
+
+
+async def get_technician_service_profile(user_id: int) -> dict[str, Any]:
+    profile = await profilesdb.find_one({"user_id": int(user_id)})
+    if not profile:
+        raise ServiceDataError("technician_not_found", "未找到技师资料。")
+    return profile
+
+
+async def instantiate_package_template(template_id: str) -> dict[str, Any]:
+    template = await templatesdb.find_one(
+        {"template_id": str(template_id), "status": "enabled"}
+    )
+    if not template:
+        raise ServiceDataError("template_not_found", "套餐模板不存在或已停用。")
+    package = dict(template["package"])
+    package.update(
+        {
+            "package_id": uuid4().hex,
+            "source_type": "template",
+            "source_template_id": template["template_id"],
+            "source_template_version": template["version"],
+            "customized_from_template": False,
+        }
+    )
+    return package
+
+
+def _fernet() -> Fernet:
+    raw = str(getattr(wbb, "SERVICE_ADDRESS_ENCRYPTION_KEY", "") or "").strip()
+    if not raw:
+        raise ServiceDataError("address_encryption_required", "服务地址加密密钥尚未配置。")
+    try:
+        return Fernet(raw.encode())
+    except (ValueError, TypeError) as exc:
+        raise ServiceDataError("invalid_address_encryption_key", "服务地址加密密钥无效。") from exc
+
+
+def _encrypt_address(payload: dict[str, Any]) -> str:
+    return _fernet().encrypt(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()).decode()
+
+
+def _decrypt_address(token: str) -> dict[str, Any]:
+    try:
+        return json.loads(_fernet().decrypt(str(token).encode()).decode())
+    except (InvalidToken, ValueError, json.JSONDecodeError) as exc:
+        raise ServiceDataError("address_unavailable", "精确地址无法解密。") from exc
+
+
+async def _schedule_address_redaction(order_id: str, closed_at: datetime) -> None:
+    retention = max(
+        1,
+        min(365, int(getattr(wbb, "SERVICE_ADDRESS_RETENTION_DAYS", 7) or 7)),
+    )
+    await addressesdb.update_one(
+        {"order_id": str(order_id), "redacted": False},
+        {
+            "$set": {
+                "redact_after": closed_at + timedelta(days=retention),
+                "updated_at": closed_at,
+            }
+        },
+    )
+
+
+def _distance_meters(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
+    radius = 6_371_000.0
+    phi1, phi2 = math.radians(lat1), math.radians(lat2)
+    delta_phi = math.radians(lat2 - lat1)
+    delta_lambda = math.radians(lon2 - lon1)
+    a = math.sin(delta_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2
+    return radius * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
+
+
+def distance_band(distance_meters: float) -> str:
+    km = max(0.0, distance_meters / 1000)
+    for threshold in (1, 3, 5, 10, 20, 50):
+        if km <= threshold:
+            return f"{threshold} 公里内"
+    return "50 公里以上"
+
+
+async def _get_package(technician_id: int, package_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
+    profile = await profilesdb.find_one({"user_id": int(technician_id)})
+    if not profile or profile.get("application_status") != "approved":
+        raise ServiceDataError("technician_not_found", "未找到已认证技师。")
+    if not profile.get("listed") or not profile.get("username"):
+        raise ServiceDataError("technician_unavailable", "该技师暂未公开接单。")
+    if not await membershipsdb.find_one({"user_id": int(technician_id), "active": True}):
+        raise ServiceDataError("technician_unavailable", "该技师当前不满足接单资格。")
+    service_profile = profile.get("service_profile") or {}
+    if not service_profile.get("is_complete") or not service_profile.get("accepting_requests", True):
+        raise ServiceDataError("technician_unavailable", "该技师暂未接单。")
+    package = next((item for item in service_profile.get("packages", []) if str(item.get("package_id")) == str(package_id)), None)
+    if not package:
+        raise ServiceDataError("package_not_found", "未找到该服务套餐。")
+    return profile, package
+
+
+async def list_available_technicians(
+    *,
+    longitude: float,
+    latitude: float,
+    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:
+        raise ServiceDataError("invalid_coordinates", "位置坐标无效。")
+    filters: dict[str, Any] = {
+        "application_status": "approved",
+        "listed": True,
+        "username": {"$nin": [None, ""]},
+        "service_profile.is_complete": True,
+        "service_profile.accepting_requests": True,
+    }
+    if query:
+        pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
+        filters["$or"] = [
+            {"username": pattern},
+            {"display_name": pattern},
+            {"service_profile.tags": pattern},
+            {"service_profile.packages.category": pattern},
+        ]
+    active_members = {
+        int(item["user_id"])
+        async for item in membershipsdb.find({"active": True}, {"user_id": 1})
+    }
+    profiles = {
+        int(item["user_id"]): item
+        async for item in profilesdb.find(filters)
+        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)}}):
+            point = location.get("point", {}).get("coordinates") or [
+                location.get("longitude"),
+                location.get("latitude"),
+            ]
+            if len(point) != 2 or point[0] is None or point[1] is None:
+                continue
+            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(
+                {
+                    "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"]))
+    total = len(values)
+    start = (max(1, page) - 1) * max(1, page_size)
+    return values[start : start + max(1, page_size)], total
+
+
+async def create_service_order(
+    *,
+    customer: Any,
+    technician_id: int,
+    package_id: str,
+    service_mode: str,
+    requirements: str,
+    longitude: float | None = None,
+    latitude: float | None = None,
+    address_text: str = "",
+) -> dict[str, Any]:
+    await ensure_service_indexes()
+    await observe_customer(customer)
+    await require_customer_allowed(int(customer.id))
+    if int(customer.id) == int(technician_id):
+        raise ServiceDataError("self_service_not_allowed", "不能向自己发起服务请求。")
+    personal_block = await blocksdb.find_one({"scope": "technician", "technician_id": int(technician_id), "customer_id": int(customer.id), "active": True})
+    if personal_block:
+        raise ServiceDataError("customer_blocked", "该技师暂不接受你的服务请求。")
+    service_settings = await get_service_settings()
+    max_open = service_settings["customer_max_open_orders"]
+    open_count = await ordersdb.count_documents({"customer_id": int(customer.id), "status": {"$in": list(ORDER_ACTIVE_STATUSES)}})
+    if open_count >= max_open:
+        raise ServiceDataError("too_many_open_orders", f"最多同时保留 {max_open} 个未关闭服务单。")
+    day_start = utc_now().replace(hour=0, minute=0, second=0, microsecond=0)
+    daily_limit = service_settings["customer_daily_request_limit"]
+    if await ordersdb.count_documents({"customer_id": int(customer.id), "created_at": {"$gte": day_start}}) >= daily_limit:
+        raise ServiceDataError("daily_request_limit", "今天发起的服务请求已达到上限。")
+    profile, package = await _get_package(int(technician_id), str(package_id))
+    if service_mode not in package["service_modes"]:
+        raise ServiceDataError("service_mode_unavailable", "该套餐不支持所选服务方式。")
+    technician_location = await locationsdb.find_one({"user_id": int(technician_id)})
+    if not technician_location:
+        raise ServiceDataError("technician_location_required", "技师尚未配置服务位置。")
+    address_payload: dict[str, Any] | None = None
+    distance = 0.0
+    if service_mode == "onsite":
+        if longitude is None or latitude is None:
+            raise ServiceDataError("customer_location_required", "上门服务必须提供位置。")
+        lon, lat = float(longitude), float(latitude)
+        if not -180 <= lon <= 180 or not -90 <= lat <= 90:
+            raise ServiceDataError("invalid_coordinates", "位置坐标无效。")
+        address = _clean_text(address_text, max_length=300, required=True)
+        point = technician_location.get("point", {}).get("coordinates") or [technician_location.get("longitude"), technician_location.get("latitude")]
+        distance = _distance_meters(float(point[0]), float(point[1]), lon, lat)
+        radius = float(package.get("service_radius_km") or 0)
+        if radius and distance > radius * 1000:
+            raise ServiceDataError("outside_service_radius", "顾客位置超出该套餐的上门服务范围。")
+        address_payload = {"kind": "customer", "longitude": lon, "latitude": lat, "address_text": address}
+    else:
+        if longitude is None or latitude is None:
+            raise ServiceDataError(
+                "customer_location_required", "到店服务必须先通过附近查找提供位置。"
+            )
+        lon, lat = float(longitude), float(latitude)
+        if not -180 <= lon <= 180 or not -90 <= lat <= 90:
+            raise ServiceDataError("invalid_coordinates", "位置坐标无效。")
+        point = technician_location.get("point", {}).get("coordinates") or [
+            technician_location.get("longitude"),
+            technician_location.get("latitude"),
+        ]
+        distance = _distance_meters(float(point[0]), float(point[1]), lon, lat)
+        venue = (profile.get("service_profile") or {}).get("venue") or {}
+        address_payload = {
+            "kind": "venue",
+            "longitude": technician_location.get("longitude"),
+            "latitude": technician_location.get("latitude"),
+            "address_text": _clean_text(venue.get("address_hint"), max_length=300, required=True),
+        }
+    now = utc_now()
+    order_id = uuid4().hex
+    order = {
+        "order_id": order_id,
+        "customer_id": int(customer.id),
+        "customer_name": _clean_text(getattr(customer, "first_name", ""), max_length=80) or f"用户 {customer.id}",
+        "technician_id": int(technician_id),
+        "technician_name": profile.get("display_name") or f"技师 {technician_id}",
+        "package_id": str(package_id),
+        "package_snapshot": package,
+        "category": package["category"],
+        "service_mode": service_mode,
+        "requirements": _clean_text(requirements, max_length=800, required=True),
+        "distance_meters": round(distance, 2),
+        "distance_band": distance_band(distance),
+        "status": "requested",
+        "version": 1,
+        "created_at": now,
+        "updated_at": now,
+    }
+    encrypted_address = _encrypt_address(address_payload)
+    await ordersdb.insert_one(order)
+    try:
+        await addressesdb.insert_one(
+            {
+                "order_id": order_id,
+                "encrypted_payload": encrypted_address,
+                "redacted": False,
+                "redact_after": None,
+                "created_at": now,
+                "updated_at": now,
+            }
+        )
+    except Exception:
+        await ordersdb.delete_one({"order_id": order_id, "status": "requested"})
+        raise
+    await record_service_event(order_id, "order_requested", actor_id=int(customer.id))
+    return order
+
+
+def normalize_quote(value: dict[str, Any], *, package: dict[str, Any]) -> dict[str, Any]:
+    currency = str(value.get("currency") or package.get("currency") or "CNY").upper()
+    if currency != str(package.get("currency") or currency):
+        raise ServiceDataError("currency_mismatch", "最终报价币种必须与套餐一致。")
+    base = _positive_decimal(value.get("base_amount"), "服务金额")
+    travel = _positive_decimal(value.get("travel_fee"), "交通费")
+    discount = _positive_decimal(value.get("discount"), "优惠金额")
+    addons = []
+    addon_total = Decimal("0")
+    for item in value.get("addons", []) or []:
+        quantity = max(1, min(999, int(item.get("quantity") or 1)))
+        unit_price = Decimal(_positive_decimal(item.get("unit_price"), "加项单价"))
+        addons.append({"name": _clean_text(item.get("name"), max_length=60, required=True), "quantity": quantity, "unit_price": format(unit_price, "f")})
+        addon_total += unit_price * quantity
+    total = Decimal(base) + Decimal(travel) + addon_total - Decimal(discount)
+    if total < 0:
+        raise ServiceDataError("invalid_total", "最终报价总额不能小于 0。")
+    provided = value.get("total_amount")
+    if provided not in (None, "") and Decimal(_positive_decimal(provided, "总价")) != total:
+        raise ServiceDataError("quote_total_mismatch", "报价明细与总价不一致。")
+    return {
+        "currency": currency,
+        "base_amount": base,
+        "travel_fee": travel,
+        "addons": addons,
+        "discount": discount,
+        "total_amount": format(total.quantize(Decimal("0.01")), "f"),
+        "note": _clean_text(value.get("note"), max_length=500),
+    }
+
+
+async def submit_service_quote(order_id: str, technician_id: int, values: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
+    await ensure_service_indexes()
+    order = await ordersdb.find_one({"order_id": str(order_id)})
+    if not order or int(order["technician_id"]) != int(technician_id):
+        raise ServiceDataError("order_not_found", "未找到可报价服务单。")
+    if order["status"] not in {"requested", "quoted"}:
+        raise ServiceDataError("invalid_order_state", "当前服务单不能修改报价。")
+    quote = normalize_quote(values, package=order["package_snapshot"])
+    version = int(order.get("quote_version") or 0) + 1
+    quote.update({"quote_id": uuid4().hex, "order_id": str(order_id), "version": version, "created_by": int(technician_id), "created_at": utc_now()})
+    try:
+        await quotesdb.insert_one(quote)
+    except DuplicateKeyError as exc:
+        raise ServiceDataError(
+            "concurrent_order_update", "服务单状态已变化,请刷新后重试。"
+        ) from exc
+    updated = await ordersdb.find_one_and_update(
+        {
+            "order_id": str(order_id),
+            "status": {"$in": ["requested", "quoted"]},
+            "version": order["version"],
+        },
+        {
+            "$set": {
+                "status": "quoted",
+                "current_quote_id": quote["quote_id"],
+                "quote_snapshot": quote,
+                "quote_version": version,
+                "updated_at": utc_now(),
+            },
+            "$inc": {"version": 1},
+        },
+        return_document=ReturnDocument.AFTER,
+    )
+    if not updated:
+        await quotesdb.delete_one({"quote_id": quote["quote_id"]})
+        raise ServiceDataError("concurrent_order_update", "服务单状态已变化,请刷新后重试。")
+    await record_service_event(order_id, "quote_submitted", actor_id=int(technician_id), metadata={"quote_id": quote["quote_id"], "version": version})
+    return updated, quote
+
+
+async def confirm_service_quote(order_id: str, customer_id: int) -> dict[str, Any]:
+    order = await ordersdb.find_one_and_update(
+        {"order_id": str(order_id), "customer_id": int(customer_id), "status": "quoted"},
+        {"$set": {"status": "confirmed", "confirmed_at": utc_now(), "updated_at": utc_now()}, "$inc": {"version": 1}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not order:
+        raise ServiceDataError("invalid_order_state", "当前报价无法确认。")
+    await record_service_event(order_id, "quote_confirmed", actor_id=int(customer_id))
+    return order
+
+
+async def reject_service_order(order_id: str, technician_id: int, reason: str) -> dict[str, Any]:
+    now = utc_now()
+    order = await ordersdb.find_one_and_update(
+        {"order_id": str(order_id), "technician_id": int(technician_id), "status": {"$in": ["requested", "quoted"]}},
+        {"$set": {"status": "rejected", "closed_at": now, "updated_at": now, "close_reason": _clean_text(reason, max_length=500, required=True)}, "$inc": {"version": 1}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not order:
+        raise ServiceDataError("invalid_order_state", "当前服务单无法拒绝。")
+    await _schedule_address_redaction(order_id, now)
+    await record_service_event(order_id, "order_rejected", actor_id=int(technician_id), reason=reason)
+    return order
+
+
+async def start_service_order(order_id: str, technician_id: int) -> dict[str, Any]:
+    order = await ordersdb.find_one_and_update(
+        {"order_id": str(order_id), "technician_id": int(technician_id), "status": "confirmed"},
+        {"$set": {"status": "in_progress", "started_at": utc_now(), "updated_at": utc_now()}, "$inc": {"version": 1}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not order:
+        raise ServiceDataError("invalid_order_state", "只有已确认服务单可以开始。")
+    await record_service_event(order_id, "service_started", actor_id=int(technician_id))
+    return order
+
+
+async def cancel_service_order(order_id: str, actor_id: int, reason: str) -> dict[str, Any]:
+    order = await ordersdb.find_one({"order_id": str(order_id)})
+    if not order or int(actor_id) not in {int(order["customer_id"]), int(order["technician_id"])}:
+        raise ServiceDataError("order_not_found", "未找到该服务单。")
+    if order["status"] not in ORDER_ACTIVE_STATUSES - {"disputed"}:
+        raise ServiceDataError("invalid_order_state", "当前服务单不能取消。")
+    status = "canceled_customer" if int(actor_id) == int(order["customer_id"]) else "canceled_technician"
+    now = utc_now()
+    updated = await ordersdb.find_one_and_update(
+        {"order_id": str(order_id), "status": order["status"], "version": order["version"]},
+        {"$set": {"status": status, "closed_at": now, "updated_at": now, "close_reason": _clean_text(reason, max_length=500, required=True)}, "$inc": {"version": 1}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not updated:
+        raise ServiceDataError("concurrent_order_update", "服务单状态已变化,请刷新后重试。")
+    await _schedule_address_redaction(order_id, now)
+    await qrdb.update_many({"order_id": str(order_id), "status": "issued"}, {"$set": {"status": "revoked", "revoked_at": now, "revoke_reason": "服务单已取消"}})
+    await record_service_event(order_id, "order_canceled", actor_id=int(actor_id), reason=reason, metadata={"status": status})
+    return updated
+
+
+async def dispute_service_order(order_id: str, actor_id: int, reason: str) -> dict[str, Any]:
+    current = await ordersdb.find_one(
+        {
+            "order_id": str(order_id),
+            "status": {"$in": list(ORDER_ACTIVE_STATUSES - {"disputed"})},
+            "$or": [{"customer_id": int(actor_id)}, {"technician_id": int(actor_id)}],
+        }
+    )
+    if not current:
+        raise ServiceDataError("invalid_order_state", "当前服务单不能发起争议。")
+    order = await ordersdb.find_one_and_update(
+        {"order_id": str(order_id), "status": current["status"], "version": current["version"]},
+        {"$set": {"status": "disputed", "status_before_dispute": current["status"], "dispute_reason": _clean_text(reason, max_length=500, required=True), "updated_at": utc_now()}, "$inc": {"version": 1}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not order:
+        raise ServiceDataError("invalid_order_state", "当前服务单不能发起争议。")
+    await qrdb.update_many({"order_id": str(order_id), "status": "issued"}, {"$set": {"status": "revoked", "revoked_at": utc_now(), "revoke_reason": "服务单争议"}})
+    await record_service_event(order_id, "order_disputed", actor_id=int(actor_id), reason=reason)
+    return order
+
+
+async def resolve_service_dispute(
+    order_id: str,
+    *,
+    actor_id: str,
+    action: str,
+    reason: str,
+) -> dict[str, Any]:
+    if action == "void":
+        return await admin_void_service_order(order_id, actor_id=actor_id, reason=reason)
+    if action != "resume":
+        raise ServiceDataError("invalid_dispute_action", "不支持该争议处理操作。")
+    current = await ordersdb.find_one({"order_id": str(order_id), "status": "disputed"})
+    if not current:
+        raise ServiceDataError("invalid_order_state", "该服务单当前不在争议中。")
+    resume_status = str(current.get("status_before_dispute") or "confirmed")
+    if resume_status == "completion_pending":
+        resume_status = "in_progress"
+    if resume_status not in {"requested", "quoted", "confirmed", "in_progress"}:
+        resume_status = "confirmed"
+    updated = await ordersdb.find_one_and_update(
+        {"order_id": str(order_id), "status": "disputed", "version": current["version"]},
+        {
+            "$set": {
+                "status": resume_status,
+                "dispute_resolved_at": utc_now(),
+                "dispute_resolution_reason": _clean_text(reason, max_length=500, required=True),
+                "updated_at": utc_now(),
+            },
+            "$unset": {"status_before_dispute": ""},
+            "$inc": {"version": 1},
+        },
+        return_document=ReturnDocument.AFTER,
+    )
+    if not updated:
+        raise ServiceDataError("concurrent_order_update", "服务单状态已变化,请刷新后重试。")
+    await record_service_event(order_id, "dispute_resolved", actor_id=actor_id, reason=reason, metadata={"status": resume_status})
+    return updated
+
+
+async def admin_void_service_order(order_id: str, *, actor_id: str, reason: str) -> dict[str, Any]:
+    now = utc_now()
+    order = await ordersdb.find_one_and_update(
+        {"order_id": str(order_id), "status": {"$ne": "voided"}},
+        {"$set": {"status": "voided", "closed_at": now, "updated_at": now, "close_reason": _clean_text(reason, max_length=500, required=True)}, "$inc": {"version": 1}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not order:
+        raise ServiceDataError("order_not_found", "未找到可作废服务单。")
+    await _schedule_address_redaction(order_id, now)
+    await qrdb.update_many({"order_id": str(order_id), "status": {"$ne": "voided"}}, {"$set": {"status": "voided", "revoked_at": now}})
+    await reviewsdb.update_many({"order_id": str(order_id), "status": "approved"}, {"$set": {"status": "voided", "moderated_at": now, "moderation_reason": reason, "updated_at": now}})
+    await recompute_review_stats()
+    await record_service_event(order_id, "order_voided", actor_id=actor_id, reason=reason)
+    return order
+
+
+async def issue_review_qr(order_id: str, technician_id: int) -> tuple[dict[str, Any], str]:
+    await ensure_service_indexes()
+    order = await ordersdb.find_one({"order_id": str(order_id), "technician_id": int(technician_id)})
+    if order and order.get("status") == "completion_pending":
+        previous = await qrdb.find_one(
+            {"order_id": str(order_id), "status": "issued"},
+            sort=[("created_at", DESCENDING)],
+        )
+        if previous and _as_utc(previous["expires_at"]) <= utc_now():
+            now = utc_now()
+            expired = await qrdb.find_one_and_update(
+                {"qr_id": previous["qr_id"], "status": "issued"},
+                {"$set": {"status": "expired", "expired_at": now}},
+                return_document=ReturnDocument.AFTER,
+            )
+            if expired:
+                await ordersdb.update_one(
+                    {
+                        "order_id": str(order_id),
+                        "status": "completion_pending",
+                        "current_review_qr_id": previous["qr_id"],
+                    },
+                    {
+                        "$set": {"status": "in_progress", "updated_at": now},
+                        "$unset": {"current_review_qr_id": ""},
+                        "$inc": {"version": 1},
+                    },
+                )
+            order = await ordersdb.find_one(
+                {"order_id": str(order_id), "technician_id": int(technician_id)}
+            )
+    if not order or order.get("status") != "in_progress":
+        raise ServiceDataError("invalid_order_state", "只有服务中的订单可以生成评价二维码。")
+    if await qrdb.find_one({"order_id": str(order_id), "status": {"$in": ["issued", "claimed"]}}):
+        raise ServiceDataError("review_qr_exists", "该服务单已经生成评价二维码。")
+    token = token_urlsafe(18)
+    now = utc_now()
+    hours = (await get_service_settings())["review_qr_expiry_hours"]
+    document = {
+        "qr_id": uuid4().hex,
+        "order_id": str(order_id),
+        "technician_id": int(technician_id),
+        "customer_id": int(order["customer_id"]),
+        "package_snapshot": order["package_snapshot"],
+        "quote_snapshot": order.get("quote_snapshot"),
+        "token_hash": hashlib.sha256(token.encode()).hexdigest(),
+        "status": "issued",
+        "expires_at": now + timedelta(hours=hours),
+        "created_at": now,
+    }
+    try:
+        await qrdb.insert_one(document)
+    except DuplicateKeyError as exc:
+        raise ServiceDataError("review_qr_exists", "该服务单已经生成评价二维码。") from exc
+    updated = await ordersdb.find_one_and_update(
+        {
+            "order_id": str(order_id),
+            "status": "in_progress",
+            "version": order["version"],
+        },
+        {
+            "$set": {
+                "status": "completion_pending",
+                "current_review_qr_id": document["qr_id"],
+                "completion_requested_at": now,
+                "updated_at": now,
+            },
+            "$inc": {"version": 1},
+        },
+        return_document=ReturnDocument.AFTER,
+    )
+    if not updated:
+        await qrdb.update_one(
+            {"qr_id": document["qr_id"], "status": "issued"},
+            {
+                "$set": {
+                    "status": "voided",
+                    "revoked_at": utc_now(),
+                    "revoke_reason": "服务单状态已变化",
+                }
+            },
+        )
+        raise ServiceDataError("concurrent_order_update", "服务单状态已变化,请刷新后重试。")
+    await record_service_event(order_id, "review_qr_issued", actor_id=int(technician_id), metadata={"qr_id": document["qr_id"]})
+    return document, token
+
+
+async def preview_review_qr(token: str, customer_id: int) -> dict[str, Any]:
+    document = await qrdb.find_one({"token_hash": hashlib.sha256(str(token).encode()).hexdigest()})
+    if not document:
+        raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
+    if int(document["customer_id"]) != int(customer_id):
+        raise ServiceDataError("review_qr_customer_mismatch", "该二维码不属于当前顾客。")
+    if document.get("status") == "claimed":
+        if await reviewsdb.find_one({"qr_id": document["qr_id"]}):
+            raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
+        return document
+    if document.get("status") != "issued":
+        raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
+    if _as_utc(document["expires_at"]) <= utc_now():
+        now = utc_now()
+        expired = await qrdb.find_one_and_update(
+            {"qr_id": document["qr_id"], "status": "issued"},
+            {"$set": {"status": "expired", "expired_at": now}},
+            return_document=ReturnDocument.AFTER,
+        )
+        if expired:
+            await ordersdb.update_one(
+                {
+                    "order_id": document["order_id"],
+                    "status": "completion_pending",
+                    "current_review_qr_id": document["qr_id"],
+                },
+                {
+                    "$set": {"status": "in_progress", "updated_at": now},
+                    "$unset": {"current_review_qr_id": ""},
+                    "$inc": {"version": 1},
+                },
+            )
+        raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
+    return document
+
+
+async def claim_review_qr(token: str, customer_id: int) -> tuple[dict[str, Any], dict[str, Any]]:
+    token_hash = hashlib.sha256(str(token).encode()).hexdigest()
+    now = utc_now()
+    await preview_review_qr(token, customer_id)
+    document = await qrdb.find_one_and_update(
+        {"token_hash": token_hash, "customer_id": int(customer_id), "status": "issued"},
+        {"$set": {"status": "claimed", "claimed_at": now}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not document:
+        raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
+    order = await ordersdb.find_one_and_update(
+        {"order_id": document["order_id"], "status": "completion_pending", "customer_id": int(customer_id)},
+        {"$set": {"status": "completed", "completed_at": now, "closed_at": now, "updated_at": now}, "$inc": {"version": 1}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not order:
+        await qrdb.update_one({"qr_id": document["qr_id"]}, {"$set": {"status": "voided"}})
+        raise ServiceDataError("invalid_order_state", "服务单状态已变化,无法完成评价。")
+    await _schedule_address_redaction(order["order_id"], now)
+    await record_service_event(order["order_id"], "service_completed", actor_id=int(customer_id), metadata={"qr_id": document["qr_id"]})
+    return document, order
+
+
+def normalize_review_template(values: dict[str, Any]) -> dict[str, Any]:
+    ratings = []
+    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 = []
+    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}
+
+
+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
+    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:
+        await reviewtemplatesdb.insert_one(document)
+    except DuplicateKeyError:
+        return await reviewtemplatesdb.find_one({"status": "active"}, sort=[("version", DESCENDING)]) or document
+    return document
+
+
+async def publish_review_template(values: dict[str, Any], *, actor_id: str) -> dict[str, Any]:
+    normalized = normalize_review_template(values)
+    await ensure_service_indexes()
+    while True:
+        current = await reviewtemplatesdb.find_one(sort=[("version", DESCENDING)])
+        version = int((current or {}).get("version") or 0) + 1
+        now = utc_now()
+        document = {
+            "template_id": uuid4().hex,
+            "version": version,
+            "status": "active",
+            **normalized,
+            "published_by": str(actor_id),
+            "created_at": now,
+            "published_at": now,
+        }
+        try:
+            await reviewtemplatesdb.insert_one(document)
+            break
+        except DuplicateKeyError:
+            continue
+    await reviewtemplatesdb.update_many(
+        {"status": "active", "template_id": {"$ne": document["template_id"]}},
+        {"$set": {"status": "retired", "retired_at": now}},
+    )
+    return document
+
+
+async def begin_review_draft(
+    *,
+    customer_id: int,
+    technician_id: int,
+    source: str,
+    template: dict[str, Any],
+    package_id: str | None = None,
+    qr_id: str | None = None,
+) -> dict[str, Any]:
+    await ensure_service_indexes()
+    now = utc_now()
+    current = await reviewdraftsdb.find_one({"customer_id": int(customer_id)})
+    identity = {
+        "technician_id": int(technician_id),
+        "source": str(source),
+        "package_id": str(package_id) if package_id else None,
+        "qr_id": str(qr_id) if qr_id else None,
+    }
+    if (
+        current
+        and all(current.get(key) == value for key, value in identity.items())
+        and _as_utc(current["expires_at"]) > now
+    ):
+        return current
+    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",
+            )
+        },
+        "rating_index": 0,
+        "text_index": 0,
+        "answers": {},
+        "created_at": now,
+        "updated_at": now,
+        "expires_at": now + timedelta(days=30),
+    }
+    await reviewdraftsdb.update_one(
+        {"customer_id": int(customer_id)}, {"$set": document}, upsert=True
+    )
+    return document
+
+
+async def save_review_draft_progress(
+    customer_id: int,
+    *,
+    answers: dict[str, Any],
+    rating_index: int,
+    text_index: int,
+) -> None:
+    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)),
+                "updated_at": utc_now(),
+            }
+        },
+    )
+
+
+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]]:
+    ratings: dict[str, int] = {}
+    texts: dict[str, str] = {}
+    total = 0.0
+    for question in template["rating_questions"]:
+        raw = answers.get(question["question_id"])
+        if raw in (None, "") and not question["required"]:
+            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
+
+
+async def submit_review(
+    *,
+    customer: Any,
+    technician_id: int,
+    source: str,
+    answers: dict[str, Any],
+    anonymous: bool,
+    package_id: str | None = None,
+    qr_id: str | None = None,
+    template_snapshot: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    await observe_customer(customer)
+    await require_customer_allowed(int(customer.id))
+    if source not in REVIEW_SOURCES:
+        raise ServiceDataError("invalid_review_source", "评价来源无效。")
+    profile = await profilesdb.find_one({"user_id": int(technician_id), "application_status": "approved"})
+    if not profile:
+        raise ServiceDataError("technician_not_found", "未找到已认证技师。")
+    if int(customer.id) == int(technician_id):
+        raise ServiceDataError("self_review_not_allowed", "不能评价自己。")
+    order_id = None
+    package_snapshot = None
+    if source == "qr_verified":
+        qr = await qrdb.find_one({"qr_id": str(qr_id), "status": "claimed", "customer_id": int(customer.id), "technician_id": int(technician_id)})
+        if not qr:
+            raise ServiceDataError("review_qr_required", "缺少已完成服务单的评价资格。")
+        if await reviewsdb.find_one({"qr_id": str(qr_id)}):
+            raise ServiceDataError("review_already_submitted", "该服务单已经提交评价。")
+        order_id = qr["order_id"]
+        package_snapshot = qr["package_snapshot"]
+    else:
+        service_profile = profile.get("service_profile") or {}
+        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": "其他"}
+    template = template_snapshot or await get_active_review_template()
+    score, rating_answers, text_answers = _score_review(template, answers)
+    normalized_content = "\n".join(
+        value.casefold() for _, value in sorted(text_answers.items()) if value
+    )
+    now = utc_now()
+    document = {
+        "review_id": uuid4().hex,
+        "technician_id": int(technician_id),
+        "technician_name": profile.get("display_name") or f"技师 {technician_id}",
+        "customer_id": int(customer.id),
+        "customer_name": _clean_text(getattr(customer, "first_name", ""), max_length=80) or f"用户 {customer.id}",
+        "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")},
+        "rating_answers": rating_answers,
+        "text_answers": text_answers,
+        "content_fingerprint": (
+            hashlib.sha256(normalized_content.encode()).hexdigest()
+            if normalized_content
+            else None
+        ),
+        "score": score,
+        "anonymous": bool(anonymous),
+        "status": "pending",
+        "created_at": now,
+        "updated_at": now,
+    }
+    if qr_id:
+        document["qr_id"] = str(qr_id)
+        document["order_id"] = order_id
+    try:
+        await reviewsdb.insert_one(document)
+    except DuplicateKeyError as exc:
+        if qr_id:
+            raise ServiceDataError(
+                "review_already_submitted", "该服务单已经提交评价。"
+            ) from exc
+        raise
+    return document
+
+
+async def moderate_review(review_id: str, action: str, *, actor_id: str, reason: str = "") -> dict[str, Any]:
+    status_map = {"approve": "approved", "reject": "rejected", "void": "voided"}
+    source_status = {"approve": "pending", "reject": "pending", "void": "approved"}
+    if action not in status_map:
+        raise ServiceDataError("invalid_review_action", "不支持该评价操作。")
+    current = await reviewsdb.find_one({"review_id": str(review_id)})
+    if not current:
+        raise ServiceDataError("review_not_found", "未找到评价。")
+    if current["status"] != source_status[action]:
+        raise ServiceDataError("invalid_review_state", "当前评价状态不能执行该审核操作。")
+    if action in {"reject", "void"} and not _clean_text(reason, max_length=500):
+        raise ServiceDataError("reason_required", "拒绝或作废必须填写原因。")
+    updated = await reviewsdb.find_one_and_update(
+        {"review_id": str(review_id), "status": source_status[action]},
+        {"$set": {"status": status_map[action], "moderated_by": str(actor_id), "moderated_at": utc_now(), "moderation_reason": _clean_text(reason, max_length=500), "updated_at": utc_now()}},
+        return_document=ReturnDocument.AFTER,
+    )
+    if not updated:
+        raise ServiceDataError("concurrent_review_update", "评价状态已变化,请刷新后重试。")
+    await recompute_review_stats()
+    return updated
+
+
+async def recompute_review_stats() -> None:
+    await ensure_service_indexes()
+    approved = await reviewsdb.find({"status": "approved"}).to_list(length=100000)
+    grouped: dict[tuple[int, str], list[dict[str, Any]]] = defaultdict(list)
+    category_scores: dict[str, list[float]] = defaultdict(list)
+    for review in approved:
+        key = (int(review["technician_id"]), str(review.get("category") or "其他"))
+        grouped[key].append(review)
+        category_scores[key[1]].append(float(review["score"]))
+    now = utc_now()
+    await statsdb.delete_many({})
+    documents = []
+    for (technician_id, category), values in grouped.items():
+        count = len(values)
+        average = sum(float(item["score"]) for item in values) / count
+        category_average = sum(category_scores[category]) / len(category_scores[category])
+        rank_score = count / (count + 5) * average + 5 / (count + 5) * category_average
+        dimensions: dict[str, list[int]] = defaultdict(list)
+        for review in values:
+            for key, score in review.get("rating_answers", {}).items():
+                dimensions[key].append(int(score))
+        documents.append(
+            {
+                "technician_id": technician_id,
+                "category": category,
+                "review_count": count,
+                "average_score": round(average, 4),
+                "category_average": round(category_average, 4),
+                "rank_score": round(rank_score, 6),
+                "eligible": count >= 3,
+                "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,
+            }
+        )
+    if documents:
+        await statsdb.insert_many(documents)
+
+
+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}
+    if category:
+        filters["category"] = str(category)
+    visible_ids = [
+        int(item["user_id"])
+        async for item in profilesdb.find(
+            {
+                "application_status": "approved",
+                "listed": True,
+                "username": {"$nin": [None, ""]},
+            },
+            {"user_id": 1},
+        )
+    ]
+    filters["technician_id"] = {"$in": visible_ids}
+    total = await statsdb.count_documents(filters)
+    items = await statsdb.find(filters).sort([("rank_score", DESCENDING), ("review_count", DESCENDING), ("last_review_at", DESCENDING)]).skip((max(1, page) - 1) * page_size).limit(page_size).to_list(length=page_size)
+    profiles = {int(item["user_id"]): item async for item in profilesdb.find({"user_id": {"$in": [item["technician_id"] for item in items]}, "application_status": "approved", "listed": True})}
+    visible = [{**item, "display_name": profiles[item["technician_id"]].get("display_name"), "username": profiles[item["technician_id"]].get("username")} for item in items if item["technician_id"] in profiles]
+    return visible, total
+
+
+async def list_public_reviews(
+    technician_id: int,
+    *,
+    page: int = 1,
+    page_size: int = 10,
+) -> tuple[list[dict[str, Any]], int]:
+    filters = {"technician_id": int(technician_id), "status": "approved"}
+    total = await reviewsdb.count_documents(filters)
+    items = await reviewsdb.find(filters).sort("moderated_at", DESCENDING).skip(
+        (max(1, page) - 1) * page_size
+    ).limit(page_size).to_list(length=page_size)
+    public_items = []
+    for item in items:
+        public_items.append(
+            {
+                "review_id": item["review_id"],
+                "source": item["source"],
+                "source_label": (
+                    "完成服务单评价"
+                    if item["source"] == "qr_verified"
+                    else "用户主动评价"
+                ),
+                "customer_name": "匿名顾客" if item.get("anonymous", True) else item.get("customer_name"),
+                "package_name": (item.get("package_snapshot") or {}).get("name"),
+                "category": item.get("category"),
+                "score": item.get("score"),
+                "rating_answers": item.get("rating_answers", {}),
+                "text_answers": item.get("text_answers", {}),
+                "approved_at": item.get("moderated_at"),
+            }
+        )
+    return public_items, total
+
+
+async def get_technician_review_summary(technician_id: int) -> dict[str, Any]:
+    items = await reviewsdb.find(
+        {"technician_id": int(technician_id), "status": "approved"},
+        {"score": 1, "source": 1},
+    ).to_list(length=100000)
+    count = len(items)
+    return {
+        "review_count": count,
+        "average_score": round(
+            sum(float(item["score"]) for item in items) / count,
+            4,
+        )
+        if count
+        else 0,
+        "source_counts": {
+            source: sum(1 for item in items if item.get("source") == source)
+            for source in REVIEW_SOURCES
+        },
+        "ranking_title": "审核评价排行",
+    }
+
+
+async def list_service_orders(*, query: str = "", status: str = "", page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
+    await ensure_service_indexes()
+    filters: dict[str, Any] = {}
+    if status:
+        filters["status"] = status
+    if query:
+        if query.isdigit():
+            filters["$or"] = [{"customer_id": int(query)}, {"technician_id": int(query)}]
+        else:
+            pattern = re.compile(re.escape(query), re.IGNORECASE)
+            filters["$or"] = [{"order_id": pattern}, {"customer_name": pattern}, {"technician_name": pattern}]
+    total = await ordersdb.count_documents(filters)
+    items = await ordersdb.find(filters).sort("created_at", DESCENDING).skip((max(1, page) - 1) * page_size).limit(page_size).to_list(length=page_size)
+    return items, total
+
+
+async def list_actor_service_orders(
+    actor_id: int,
+    *,
+    role: str = "all",
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    if role == "customer":
+        filters: dict[str, Any] = {"customer_id": int(actor_id)}
+    elif role == "technician":
+        filters = {"technician_id": int(actor_id)}
+    else:
+        filters = {
+            "$or": [
+                {"customer_id": int(actor_id)},
+                {"technician_id": int(actor_id)},
+            ]
+        }
+    total = await ordersdb.count_documents(filters)
+    items = await ordersdb.find(filters).sort("created_at", DESCENDING).skip(
+        (max(1, page) - 1) * page_size
+    ).limit(page_size).to_list(length=page_size)
+    return items, total
+
+
+async def get_admin_service_order(order_id: str) -> dict[str, Any]:
+    order = await ordersdb.find_one({"order_id": str(order_id)})
+    if not order:
+        raise ServiceDataError("order_not_found", "未找到服务单。")
+    result = dict(order)
+    result["quotes"] = await quotesdb.find({"order_id": str(order_id)}).sort(
+        "version", ASCENDING
+    ).to_list(length=100)
+    result["events"] = await eventsdb.find({"order_id": str(order_id)}).sort(
+        "created_at", ASCENDING
+    ).to_list(length=500)
+    result["qr_records"] = await qrdb.find({"order_id": str(order_id)}).sort(
+        "created_at", DESCENDING
+    ).to_list(length=100)
+    return result
+
+
+async def list_review_qr_records(
+    *,
+    status: str = "",
+    query: str = "",
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    filters: dict[str, Any] = {}
+    if status:
+        filters["status"] = status
+    if query:
+        filters["$or"] = [
+            {"order_id": re.compile(re.escape(query), re.IGNORECASE)},
+            {"qr_id": re.compile(re.escape(query), re.IGNORECASE)},
+            {"customer_id": int(query) if query.isdigit() else -1},
+            {"technician_id": int(query) if query.isdigit() else -1},
+        ]
+    total = await qrdb.count_documents(filters)
+    items = await qrdb.find(filters).sort("created_at", DESCENDING).skip(
+        (max(1, page) - 1) * page_size
+    ).limit(page_size).to_list(length=page_size)
+    for item in items:
+        item.pop("token_hash", None)
+    return items, total
+
+
+async def list_reviews(*, status: str = "", source: str = "", query: str = "", page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
+    await ensure_service_indexes()
+    filters: dict[str, Any] = {}
+    if status:
+        filters["status"] = status
+    if source:
+        filters["source"] = source
+    if query:
+        pattern = re.compile(re.escape(query), re.IGNORECASE)
+        filters["$or"] = [{"technician_name": pattern}, {"customer_name": pattern}, {"review_id": pattern}]
+    total = await reviewsdb.count_documents(filters)
+    items = await reviewsdb.find(filters).sort("created_at", DESCENDING).skip((max(1, page) - 1) * page_size).limit(page_size).to_list(length=page_size)
+    for item in items:
+        item["customer_review_count"] = await reviewsdb.count_documents({"customer_id": item["customer_id"], "technician_id": item["technician_id"]})
+        item["customer_review_count_30d"] = await reviewsdb.count_documents({"customer_id": item["customer_id"], "technician_id": item["technician_id"], "created_at": {"$gte": utc_now() - timedelta(days=30)}})
+        item["customer_review_count_90d"] = await reviewsdb.count_documents({"customer_id": item["customer_id"], "technician_id": item["technician_id"], "created_at": {"$gte": utc_now() - timedelta(days=90)}})
+        item["similar_content_count"] = (
+            await reviewsdb.count_documents(
+                {
+                    "customer_id": item["customer_id"],
+                    "technician_id": item["technician_id"],
+                    "content_fingerprint": item["content_fingerprint"],
+                }
+            )
+            if item.get("content_fingerprint")
+            else 0
+        )
+    return items, total
+
+
+async def get_order_for_actor(order_id: str, actor_id: int, *, reveal_address: bool = False) -> dict[str, Any]:
+    order = await ordersdb.find_one({"order_id": str(order_id)})
+    if not order or int(actor_id) not in {int(order["customer_id"]), int(order["technician_id"])}:
+        raise ServiceDataError("order_not_found", "未找到服务单。")
+    result = dict(order)
+    can_reveal = order["status"] in {"confirmed", "in_progress", "completion_pending", "completed", "disputed"}
+    if reveal_address and can_reveal:
+        address = await addressesdb.find_one({"order_id": str(order_id), "redacted": False})
+        if address:
+            result["exact_address"] = _decrypt_address(address["encrypted_payload"])
+    return result
+
+
+async def admin_reveal_order_address(order_id: str, *, actor_id: str, reason: str) -> dict[str, Any]:
+    reason = _clean_text(reason, max_length=500, required=True)
+    order = await ordersdb.find_one({"order_id": str(order_id)})
+    address = await addressesdb.find_one({"order_id": str(order_id), "redacted": False})
+    if not order or not address:
+        raise ServiceDataError("address_unavailable", "精确地址已脱敏或不存在。")
+    await record_service_event(order_id, "admin_address_revealed", actor_id=actor_id, reason=reason)
+    return _decrypt_address(address["encrypted_payload"])
+
+
+async def redact_expired_addresses() -> int:
+    now = utc_now()
+    candidates = await addressesdb.find({"redacted": False, "redact_after": {"$lte": now}}).to_list(length=1000)
+    count = 0
+    for item in candidates:
+        order = await ordersdb.find_one({"order_id": item["order_id"]})
+        if not order or order.get("status") not in ORDER_TERMINAL_STATUSES:
+            continue
+        result = await addressesdb.update_one({"_id": item["_id"], "redacted": False}, {"$set": {"redacted": True, "redacted_at": now, "updated_at": now}, "$unset": {"encrypted_payload": ""}})
+        if result.modified_count:
+            await ordersdb.update_one(
+                {"order_id": item["order_id"]},
+                {
+                    "$set": {"address_redacted": True, "updated_at": now},
+                    "$unset": {"distance_meters": ""},
+                },
+            )
+        count += result.modified_count
+    return count
+
+
+async def set_customer_block(*, customer_id: int, scope: str, technician_id: int | None, active: bool, actor_id: int | str, reason: str) -> dict[str, Any]:
+    if scope not in {"global", "technician"}:
+        raise ServiceDataError("invalid_block_scope", "拉黑范围无效。")
+    if scope == "technician" and technician_id is None:
+        raise ServiceDataError("technician_required", "技师拉黑必须指定技师。")
+    now = utc_now()
+    await blocksdb.update_one(
+        {"scope": scope, "technician_id": int(technician_id or 0), "customer_id": int(customer_id)},
+        {"$set": {"active": bool(active), "reason": _clean_text(reason, max_length=500, required=active), "actor_id": actor_id, "updated_at": now}, "$setOnInsert": {"created_at": now}},
+        upsert=True,
+    )
+    return await blocksdb.find_one({"scope": scope, "technician_id": int(technician_id or 0), "customer_id": int(customer_id)}) or {}
+
+
+async def set_technician_customer_block(
+    *,
+    technician_id: int,
+    customer_id: int,
+    active: bool,
+    reason: str,
+) -> dict[str, Any]:
+    profile = await profilesdb.find_one(
+        {"user_id": int(technician_id), "application_status": "approved"}
+    )
+    if not profile:
+        raise ServiceDataError("technician_required", "只有已认证技师可以管理个人拉黑。")
+    if not await ordersdb.find_one(
+        {"technician_id": int(technician_id), "customer_id": int(customer_id)}
+    ):
+        raise ServiceDataError("customer_relationship_required", "只能拉黑曾向你发起服务请求的顾客。")
+    return await set_customer_block(
+        customer_id=int(customer_id),
+        scope="technician",
+        technician_id=int(technician_id),
+        active=active,
+        actor_id=int(technician_id),
+        reason=reason,
+    )
+
+
+async def list_customer_blocks(
+    *,
+    scope: str = "global",
+    active: bool | None = None,
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    filters: dict[str, Any] = {"scope": scope}
+    if active is not None:
+        filters["active"] = active
+    total = await blocksdb.count_documents(filters)
+    items = await blocksdb.find(filters).sort("updated_at", DESCENDING).skip(
+        (max(1, page) - 1) * page_size
+    ).limit(page_size).to_list(length=page_size)
+    return items, total
+
+
+async def create_service_report(*, reporter_id: int, target_type: str, target_id: str, reason: str) -> dict[str, Any]:
+    if target_type not in {"technician", "customer", "order", "review"}:
+        raise ServiceDataError("invalid_report_target", "举报对象无效。")
+    document = {"report_id": uuid4().hex, "reporter_id": int(reporter_id), "target_type": target_type, "target_id": str(target_id), "reason": _clean_text(reason, max_length=800, required=True), "status": "open", "created_at": utc_now()}
+    await reportsdb.insert_one(document)
+    return document
+
+
+async def list_service_reports(
+    *,
+    status: str = "",
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    filters = {"status": status} if status else {}
+    total = await reportsdb.count_documents(filters)
+    items = await reportsdb.find(filters).sort("created_at", DESCENDING).skip(
+        (max(1, page) - 1) * page_size
+    ).limit(page_size).to_list(length=page_size)
+    return items, total
+
+
+async def moderate_service_report(
+    report_id: str,
+    *,
+    action: str,
+    actor_id: str,
+    reason: str,
+) -> dict[str, Any]:
+    status_map = {"resolve": "resolved", "dismiss": "dismissed"}
+    if action not in status_map:
+        raise ServiceDataError("invalid_report_action", "不支持该举报操作。")
+    updated = await reportsdb.find_one_and_update(
+        {"report_id": str(report_id), "status": "open"},
+        {
+            "$set": {
+                "status": status_map[action],
+                "handled_by": str(actor_id),
+                "handled_at": utc_now(),
+                "handling_reason": _clean_text(reason, max_length=500, required=True),
+            }
+        },
+        return_document=ReturnDocument.AFTER,
+    )
+    if not updated:
+        raise ServiceDataError("report_not_found", "举报不存在或已处理。")
+    return updated
+
+
+async def fulfillment_metrics() -> dict[str, Any]:
+    await ensure_service_indexes()
+    counts = {status: await ordersdb.count_documents({"status": status}) for status in ORDER_STATUSES}
+    valid_requests = sum(counts.values()) - counts["voided"]
+    quoted = await ordersdb.count_documents(
+        {"status": {"$ne": "voided"}, "current_quote_id": {"$exists": True}}
+    )
+    confirmed = await ordersdb.count_documents(
+        {"status": {"$ne": "voided"}, "confirmed_at": {"$exists": True}}
+    )
+    completed = counts["completed"]
+    canceled = counts["canceled_customer"] + counts["canceled_technician"]
+    directory_views = await eventsdb.count_documents({"event_type": "directory_viewed"})
+    technician_views = await eventsdb.count_documents({"event_type": "technician_viewed"})
+    request_starts = await eventsdb.count_documents({"event_type": "request_started"})
+    return {
+        "counts": counts,
+        "valid_requests": valid_requests,
+        "quoted_requests": quoted,
+        "confirmed_requests": confirmed,
+        "directory_views": directory_views,
+        "technician_views": technician_views,
+        "request_starts": request_starts,
+        "request_submission_rate": min(1.0, round(valid_requests / request_starts, 4))
+        if request_starts
+        else 0,
+        "quote_rate": round(quoted / valid_requests, 4) if valid_requests else 0,
+        "customer_confirmation_rate": round(confirmed / quoted, 4) if quoted else 0,
+        "match_success_rate": round(confirmed / valid_requests, 4) if valid_requests else 0,
+        "fulfillment_rate": round(completed / confirmed, 4) if confirmed else 0,
+        "cancellation_rate": round(canceled / valid_requests, 4) if valid_requests else 0,
+        "cancellation_distribution": {
+            "customer": counts["canceled_customer"],
+            "technician": counts["canceled_technician"],
+        },
+        "scope_note": "仅统计平台服务单;直接 Telegram 私聊成交不在统计范围。",
+    }