Forráskód Böngészése

feat: simplify technician consultation and reviews

AI-Co-Authored-By: Codex
chendeben 2 hónapja
szülő
commit
882fc18461

+ 16 - 22
README.md

@@ -6,35 +6,29 @@
     Telegram Group Manager Bot + Userbot Written In Python Using Pyrogram.
 </h3>
 
-## 技师服务最小履约闭环
+## 技师咨询与评价
 
-当前版本要求 Python 3.12,并提供技师展示、附近查找、服务请求、最终报价、
-状态流转、完成评价二维码、主动评价、审核评价排行和对应管理后台。
-首期不处理支付、退款或预约时间。
+当前版本要求 Python 3.12,产品主流程是:浏览技师与服务信息 → Telegram 私聊
+咨询 → 顾客直接评价 → 管理员审核 → 自动发布到技师公开评价话题并参与排行。
+平台不处理预约、支付、退款或精确服务地址。
 
-启用服务地址前必须配置独立 Fernet 密钥:
-
-```console
-uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
-```
-
-将输出写入 `SERVICE_ADDRESS_ENCRYPTION_KEY`。精确地址不会进入二维码、评价或
-普通接口,服务单进入终态 7 天后由后台任务自动脱敏。常用顾客/技师命令:
-
-技师套餐默认通过 Telegram Mini App 快速创建,入口地址由
+技师服务默认通过 Telegram Mini App 快速创建,入口地址由
 `SERVICE_TECHNICIAN_MINI_APP_URL` 配置。自助接口会校验 Telegram `initData`
 签名并绑定当前技师,不能通过请求参数修改其他技师资料。系统首次启动时会幂等
 内置快速到店、快速上门和按小时服务模板;管理员可以继续编辑、停用或归档,
-后续启动不会覆盖管理员修改。
+后续启动不会覆盖管理员修改。技师只需确认服务价格、内容、时长、额外费用说明
+和公开区域;具体地址由双方私聊。
+
+公开评价使用一个开启“话题”的公开超级群。将 Bot 设为管理员并授予管理话题
+权限,然后在后台填写群 Chat ID 和公开用户名;系统会为每位技师自动创建独立
+话题,并在评价审核通过后立即发布。
 
-- `/find_technician`:查找附近技师并发起服务请求。
-- `/my_services`:查看服务单并继续报价、确认、开始、取消或争议流程。
-- `/technician_profile`:技师选择后台模板或自建套餐并发布服务资料。
-- `/review_qr`:技师为服务中的订单生成完成确认二维码。
-- `/review_teacher`:顾客主动评价技师。
-- `/technician_top`:查看审核评价排行。
+- `/find_technician`:浏览技师、服务和公开评价。
+- `/technician_profile`:技师通过 Mini App 快速发布服务。
+- `/review_teacher`:顾客选择技师后直接评价,无需服务单或完成确认。
+- `/technician_top`:从第一条审核评价开始展示排行。
 
-旧 `/teacher_*` 命令继续作为兼容别名。
+旧 `/teacher_*`、`/my_services` 和 `/review_qr` 命令继续作为兼容入口。
 
 <p align="center">
     <a href="https://python.org">

+ 1 - 1
admin-web/src/layouts/AdminLayout.tsx

@@ -34,7 +34,7 @@ const menuItems = [
   { path: '/giveaways', name: '抽奖管理', icon: <Gift size={18} /> },
   { path: '/points', name: '积分管理', icon: <Star size={18} /> },
   { path: '/directory', name: '技师与附近', icon: <MapPinned size={18} /> },
-  { path: '/services', name: '技师服务', icon: <Wrench size={18} /> },
+  { path: '/services', name: '咨询与评价', icon: <Wrench size={18} /> },
   { path: '/audit', name: '操作日志', icon: <ClipboardList size={18} /> },
   { path: '/settings', name: '系统设置', icon: <Settings size={18} /> },
 ];

+ 136 - 169
admin-web/src/pages/Services.tsx

@@ -79,29 +79,9 @@ type TemplateFormValue = {
   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;
+  price_display: 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;
-  }>;
+  additional_fee_description: string;
 };
 
 export default function ServicesPage() {
@@ -129,7 +109,7 @@ export default function ServicesPage() {
   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 [reviewTemplateForm] = Form.useForm<Pick<ServiceReviewTemplate, 'questions'>>();
   const [orderReasonForm] = Form.useForm<{ reason: string }>();
   const [reviewReasonForm] = Form.useForm<{ reason: string }>();
   const [addressForm] = Form.useForm<{ reason: string }>();
@@ -169,12 +149,11 @@ export default function ServicesPage() {
       setReports(reportList);
       setReviewTemplate(activeReviewTemplate);
       reviewTemplateForm.setFieldsValue({
-        rating_questions: activeReviewTemplate.rating_questions,
-        text_questions: activeReviewTemplate.text_questions,
+        questions: activeReviewTemplate.questions,
       });
       await Promise.all([loadOrders(orders.page), loadReviews(reviews.page)]);
     } catch (error) {
-      message.error(error instanceof Error ? error.message : '履约数据加载失败');
+      message.error(error instanceof Error ? error.message : '咨询与评价数据加载失败');
     } finally {
       setLoading(false);
     }
@@ -191,7 +170,7 @@ export default function ServicesPage() {
     );
     setSettings(saved);
     settingsForm.setFieldsValue(saved);
-    message.success('履约设置已更新');
+    message.success('咨询与评价设置已更新');
   };
 
   const openTemplate = (item?: ServicePackageTemplate) => {
@@ -199,12 +178,10 @@ export default function ServicesPage() {
     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,
+        category: '通用服务',
+        duration_minutes: 60,
+        additional_fee_description: '其他费用请提前私聊沟通',
       });
       return;
     }
@@ -216,23 +193,12 @@ export default function ServicesPage() {
       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,
+      price_display:
+        value.price_display ||
+        `${value.currency} ${value.min_price}/${value.price_unit === 'per_hour' ? '小时' : '次'}`,
       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,
+      additional_fee_description:
+        value.travel_fee.description || '其他费用请提前私聊沟通',
     });
   };
 
@@ -246,28 +212,27 @@ export default function ServicesPage() {
         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',
+        tags: [],
+        service_modes: ['at_store', 'onsite'],
+        price_mode: 'negotiable',
+        currency: 'CNY',
+        price_unit: 'per_service',
+        min_price: '0',
+        max_price: '0',
+        price_display: values.price_display,
         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,
+        service_radius_km: null,
+        included_items: '',
+        excluded_items: '',
+        preparation: '',
+        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',
+          mode: 'quoted',
+          amount: '0',
+          per_km: '0',
+          description: values.additional_fee_description,
         },
-        addons: (values.addons || []).map((addon) => ({
-          ...addon,
-          extra_minutes: addon.extra_minutes || 0,
-        })),
+        addons: [],
       },
     };
     await apiRequest(
@@ -336,20 +301,19 @@ export default function ServicesPage() {
     });
   };
 
-  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 approveReview = async (review: ServiceReview) => {
+    const approved = await apiRequest<ServiceReview>(
+      `/directory/reviews/${review.review_id}/actions`,
+      jsonOptions('POST', { action: 'approve', confirm: true }),
+    );
+    if (approved.topic_publication?.status === 'published') {
+      message.success('评价已审核通过并发布到技师公开话题');
+    } else if (approved.topic_publication?.status === 'failed') {
+      message.warning(`评价已通过,但话题发布失败:${approved.topic_publication.message}`);
+    } else {
+      message.warning('评价已通过;配置公开评价群后可自动发布');
+    }
+    await refresh();
   };
 
   const submitReviewAction = async ({ reason }: { reason: string }) => {
@@ -364,7 +328,7 @@ export default function ServicesPage() {
     await refresh();
   };
 
-  const publishReviewTemplate = async (values: Pick<ServiceReviewTemplate, 'rating_questions' | 'text_questions'>) => {
+  const publishReviewTemplate = async (values: Pick<ServiceReviewTemplate, 'questions'>) => {
     const published = await apiRequest<ServiceReviewTemplate>(
       '/directory/review-template',
       jsonOptions('PUT', { ...values, confirm: true }),
@@ -423,12 +387,12 @@ export default function ServicesPage() {
       ),
     },
     {
-      title: '方式 / 参考价',
+      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 direction="vertical" size={0}>
+          <Typography.Text>{item.package.price_display || `${item.package.currency} ${item.package.min_price}`}</Typography.Text>
+          <Typography.Text type="secondary">约 {item.package.duration_minutes || '-'} 分钟</Typography.Text>
         </Space>
       ),
     },
@@ -481,7 +445,22 @@ export default function ServicesPage() {
     { 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: '评价内容',
+      minWidth: 260,
+      ellipsis: true,
+      render: (_, item) =>
+        [
+          ...Object.values(item.choice_answers || {}).flat(),
+          ...Object.values(item.text_answers || {}),
+        ].join(';') || '-',
+    },
+    {
+      title: '公开话题',
+      width: 110,
+      render: (_, item) =>
+        item.topic_url ? <a href={item.topic_url} target="_blank" rel="noreferrer">查看</a> : '-',
+    },
     { title: '状态', dataIndex: 'status', width: 100, render: (value) => <Tag>{value === 'pending' ? '待审核' : value === 'approved' ? '已批准' : value === 'rejected' ? '已拒绝' : '已作废'}</Tag> },
     {
       title: '操作',
@@ -489,7 +468,7 @@ export default function ServicesPage() {
       fixed: 'right',
       render: (_, item) => (
         <Space>
-          {item.status === 'pending' ? <Button size="small" type="primary" onClick={() => approveReview(item)}>批准</Button> : null}
+          {item.status === 'pending' ? <Button size="small" type="primary" onClick={() => void 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>
@@ -500,14 +479,14 @@ export default function ServicesPage() {
   return (
     <>
       <PageHeader
-        title="技师服务"
+        title="技师咨询与评价"
         actions={<Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void refresh()}>刷新</Button>}
       />
       <Alert
         showIcon
         type="info"
-        message="最小履约闭环"
-        description="仅统计平台服务单;不处理支付、退款或预约时间。主动评价与完成服务单评价审核通过后同等计分,因此公开榜单统一称为“审核评价排行”。"
+        message="咨询平台模式"
+        description="顾客直接查看技师、私聊咨询并提交评价;管理员审核通过后,评价立即公开到该技师的独立话题并参与排行。"
         style={{ marginBottom: 16 }}
       />
       <Surface>
@@ -530,14 +509,20 @@ export default function ServicesPage() {
                   </Row>
                   <Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>{metrics?.scope_note}</Typography.Paragraph>
                   <Divider />
-                  <Typography.Title level={5}>顾客限额与二维码</Typography.Title>
+                  <Typography.Title level={5}>公开评价话题</Typography.Title>
+                  <Alert
+                    showIcon
+                    type="warning"
+                    message="请使用已开启话题的公开超级群"
+                    description="把当前 Bot 设为管理员并授予管理话题权限。每位技师会自动创建独立话题,审核通过的评价会立即发布。"
+                    style={{ marginBottom: 16 }}
+                  />
                   <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>
+                      <Col xs={24} md={12}><Form.Item name="review_forum_chat_id" label="公开群 Chat ID" extra="例如:-1001234567890"><Input /></Form.Item></Col>
+                      <Col xs={24} md={12}><Form.Item name="review_forum_username" label="公开群用户名" extra="例如:technician_reviews,无需填写 @"><Input /></Form.Item></Col>
                     </Row>
-                    <Button type="primary" htmlType="submit" disabled={!settings}>保存设置</Button>
+                    <Button type="primary" htmlType="submit" disabled={!settings}>保存公开群设置</Button>
                   </Form>
                 </>
               ),
@@ -585,42 +570,60 @@ export default function ServicesPage() {
               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">
+                  <Alert showIcon type="warning" message="发布会创建新版本" description="支持单选、多选和输入框。单选项用于计算评分,所有单选问题的权重必须合计 100%;历史评价不受影响。" style={{ marginBottom: 16 }} />
+                  <Typography.Title level={5}>评价问题(1–8 个)</Typography.Title>
+                  <Form.List name="questions">
                     {(fields, { add, remove }) => (
-                      <>
+                      <Space direction="vertical" size={16} style={{ width: '100%' }}>
                         {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>
+                          <Card
+                            key={field.key}
+                            size="small"
+                            title={`问题 ${field.name + 1}`}
+                            extra={<Button danger type="text" aria-label="删除问题" icon={<Trash2 size={16} />} onClick={() => remove(field.name)} />}
+                          >
+                            <Row gutter={12}>
+                              <Col xs={24} md={12}><Form.Item {...field} name={[field.name, 'label']} label="问题" rules={[{ required: true, message: '请输入问题' }]}><Input placeholder="例如:这次服务体验怎么样?" /></Form.Item></Col>
+                              <Col xs={12} md={6}><Form.Item {...field} name={[field.name, 'type']} label="控件" rules={[{ required: true }]}><Select options={[{ value: 'single_choice', label: '单选' }, { value: 'multiple_choice', label: '多选' }, { value: 'text', label: '输入框' }]} /></Form.Item></Col>
+                              <Col xs={12} md={6}><Form.Item {...field} name={[field.name, 'required']} label="是否必填"><Select options={[{ value: true, label: '必填' }, { value: false, label: '可跳过' }]} /></Form.Item></Col>
+                            </Row>
+                            <Form.Item {...field} name={[field.name, 'description']} label="提示文字"><Input /></Form.Item>
                             <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>
+                            <Form.Item noStyle shouldUpdate>
+                              {({ getFieldValue }) => {
+                                const questionType = getFieldValue(['questions', field.name, 'type']);
+                                if (questionType === 'text') {
+                                  return <Form.Item {...field} name={[field.name, 'max_length']} label="最大字数"><InputNumber min={50} max={1000} /></Form.Item>;
+                                }
+                                return (
+                                  <>
+                                    {questionType === 'single_choice' ? (
+                                      <Form.Item {...field} name={[field.name, 'weight']} label="评分权重" rules={[{ required: true, message: '请输入权重' }]}><InputNumber min={1} max={100} addonAfter="%" /></Form.Item>
+                                    ) : null}
+                                    <Typography.Text strong>选项(2–8 个)</Typography.Text>
+                                    <Form.List name={[field.name, 'options']}>
+                                      {(optionFields, optionActions) => (
+                                        <Space direction="vertical" style={{ width: '100%', marginTop: 8 }}>
+                                          {optionFields.map((optionField) => (
+                                            <Row gutter={8} key={optionField.key} align="middle">
+                                              <Col flex="auto"><Form.Item {...optionField} name={[optionField.name, 'label']} rules={[{ required: true, message: '请输入选项' }]} style={{ marginBottom: 0 }}><Input placeholder="选项文字" /></Form.Item></Col>
+                                              {questionType === 'single_choice' ? <Col flex="100px"><Form.Item {...optionField} name={[optionField.name, 'score']} rules={[{ required: true, message: '分值' }]} style={{ marginBottom: 0 }}><InputNumber min={1} max={5} addonAfter="分" /></Form.Item></Col> : null}
+                                              <Col flex="44px"><Button danger type="text" aria-label="删除选项" icon={<Trash2 size={16} />} onClick={() => optionActions.remove(optionField.name)} /></Col>
+                                              <Form.Item {...optionField} name={[optionField.name, 'value']} hidden><Input /></Form.Item>
+                                            </Row>
+                                          ))}
+                                          <Button size="small" icon={<Plus size={14} />} disabled={optionFields.length >= 8} onClick={() => optionActions.add({ value: `option_${Date.now()}`, label: '', score: questionType === 'single_choice' ? 5 : undefined })}>增加选项</Button>
+                                        </Space>
+                                      )}
+                                    </Form.List>
+                                  </>
+                                );
+                              }}
+                            </Form.Item>
+                          </Card>
                         ))}
-                        <Button icon={<Plus size={16} />} disabled={fields.length >= 2} onClick={() => add({ question_id: `text_${Date.now()}`, required: false, max_length: 500 })}>增加文字问题</Button>
-                      </>
+                        <Button icon={<Plus size={16} />} disabled={fields.length >= 8} onClick={() => add({ question_id: `question_${Date.now()}`, type: 'single_choice', label: '', required: true, weight: 100, options: [{ value: 'excellent', label: '非常满意', score: 5 }, { value: 'good', label: '满意', score: 4 }, { value: 'average', label: '一般', score: 3 }, { value: 'poor', label: '不满意', score: 2 }, { value: 'bad', label: '非常不满意', score: 1 }] })}>增加问题</Button>
+                      </Space>
                     )}
                   </Form.List>
                   <Divider />
@@ -684,51 +687,15 @@ export default function ServicesPage() {
           <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="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>
+          <Form.Item name="price_display" label="默认服务价格" extra="直接填写给技师和顾客看的价格文字,例如:300 元/次起。" rules={[{ required: true }]}><Input /></Form.Item>
+          <Form.Item name="description" label="默认服务内容" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
           <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>
+            <Col xs={24} md={8}><Form.Item name="duration_minutes" label="默认服务时长" rules={[{ required: true }]}><InputNumber min={15} max={480} addonAfter="分钟" style={{ width: '100%' }} /></Form.Item></Col>
+            <Col xs={24} md={16}><Form.Item name="additional_fee_description" label="默认额外费用说明" rules={[{ required: true }]}><Input placeholder="例如:上门车费根据距离另行沟通" /></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>
 

+ 130 - 452
admin-web/src/pages/TechnicianMiniApp.tsx

@@ -1,16 +1,12 @@
 import {
   Alert,
   Button,
-  Checkbox,
-  Collapse,
   Empty,
   Form,
   Input,
   InputNumber,
   message,
   Modal,
-  Radio,
-  Select,
   Spin,
   Switch,
   Tag,
@@ -19,13 +15,12 @@ import {
 import {
   ArrowLeft,
   Check,
-  Clock3,
+  ExternalLink,
   MapPin,
   Pencil,
   Plus,
-  SlidersHorizontal,
 } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState } from 'react';
 
 import {
   initializeTelegramMiniApp,
@@ -34,7 +29,6 @@ import {
   technicianJsonOptions,
 } from '@/services/technicianApi';
 import type {
-  ServiceMode,
   ServicePackage,
   TechnicianMiniAppBootstrap,
   TechnicianSelfServiceContext,
@@ -48,63 +42,17 @@ type TemplateItem = TechnicianMiniAppBootstrap['templates'][number];
 interface PackageEditor {
   index: number | null;
   package: ServicePackage;
-  templatePackage?: ServicePackage;
 }
 
 interface PackageFormValue {
   name: string;
-  category: string;
+  price_display: string;
   description: string;
-  currency: string;
-  price_mode: ServicePackage['price_mode'];
-  price_unit: ServicePackage['price_unit'];
-  min_price?: number;
-  max_price?: number;
   duration_minutes: number;
-  service_modes: ServiceMode[];
-  service_radius_km?: number;
+  additional_fee_description: string;
   public_area_text: string;
-  venue_name?: string;
-  venue_address?: string;
-  included_items?: string;
-  excluded_items?: string;
-  preparation?: string;
-  out_of_range_policy?: string;
-  travel_fee_mode: ServicePackage['travel_fee']['mode'];
-  travel_fee_amount?: number;
-  travel_fee_per_km?: number;
-  profile_headline?: string;
-  profile_bio?: string;
-  contact_hours?: string;
-  onsite_description?: string;
 }
 
-const modeOptions = [
-  { label: '到店', value: 'at_store' },
-  { label: '上门', value: 'onsite' },
-];
-
-const priceModeOptions = [
-  { label: '固定价', value: 'fixed' },
-  { label: '起步价', value: 'starting_at' },
-  { label: '区间价', value: 'range' },
-  { label: '面议', value: 'negotiable' },
-];
-
-const priceUnitOptions = [
-  { label: '每次', value: 'per_service' },
-  { label: '每小时', value: 'per_hour' },
-  { label: '每件', value: 'per_item' },
-  { label: '每次上门', value: 'per_visit' },
-];
-
-const travelModeOptions = [
-  { label: '已包含', value: 'included' },
-  { label: '固定交通费', value: 'fixed' },
-  { label: '按公里', value: 'per_km' },
-  { label: '报价时确认', value: 'quoted' },
-];
-
 function clonePackage(value: ServicePackage): ServicePackage {
   return JSON.parse(JSON.stringify(value)) as ServicePackage;
 }
@@ -116,13 +64,8 @@ function newPackageId(): string {
   return `${Date.now()}${Math.random().toString(16).slice(2)}`;
 }
 
-function numeric(value?: string | number | null): number | undefined {
-  if (value === undefined || value === null || value === '') return undefined;
-  const parsed = Number(value);
-  return Number.isFinite(parsed) ? parsed : undefined;
-}
-
 function packagePrice(packageItem: ServicePackage): string {
+  if (packageItem.price_display) return packageItem.price_display;
   if (packageItem.price_mode === 'negotiable') return '价格面议';
   const unit = {
     per_service: '次',
@@ -137,7 +80,7 @@ function packagePrice(packageItem: ServicePackage): string {
   return `${packageItem.currency} ${packageItem.min_price}${suffix}/${unit}`;
 }
 
-function templatePackage(template: TemplateItem): ServicePackage {
+function fromTemplate(template: TemplateItem): ServicePackage {
   return {
     ...clonePackage(template.package),
     package_id: newPackageId(),
@@ -148,93 +91,43 @@ function templatePackage(template: TemplateItem): ServicePackage {
   };
 }
 
-function editablePackageValues(
+function editableValues(
   packageItem: ServicePackage,
   profile?: TechnicianServiceProfile | null,
 ): PackageFormValue {
   return {
     name: packageItem.name,
-    category: packageItem.category,
+    price_display: packagePrice(packageItem),
     description: packageItem.description,
-    currency: packageItem.currency,
-    price_mode: packageItem.price_mode,
-    price_unit: packageItem.price_unit,
-    min_price: numeric(packageItem.min_price),
-    max_price: numeric(packageItem.max_price),
     duration_minutes: packageItem.duration_minutes || 60,
-    service_modes: packageItem.service_modes,
-    service_radius_km: numeric(packageItem.service_radius_km),
+    additional_fee_description:
+      packageItem.travel_fee?.description || '其他费用请在私聊中提前沟通确认',
     public_area_text: profile?.public_area_text || '',
-    venue_name: profile?.venue?.name || '工作地点',
-    venue_address: profile?.venue?.address_hint || '',
-    included_items: packageItem.included_items,
-    excluded_items: packageItem.excluded_items,
-    preparation: packageItem.preparation,
-    out_of_range_policy: packageItem.out_of_range_policy,
-    travel_fee_mode: packageItem.travel_fee.mode,
-    travel_fee_amount: numeric(packageItem.travel_fee.amount),
-    travel_fee_per_km: numeric(packageItem.travel_fee.per_km),
-    profile_headline: profile?.headline || packageItem.name,
-    profile_bio: profile?.bio || packageItem.description,
-    contact_hours: profile?.contact_hours || '请通过 Telegram 私聊确认服务时间',
-    onsite_description:
-      profile?.onsite_policy?.description ||
-      `${packageItem.service_radius_km || 20} 公里内可上门,超区请先沟通。`,
   };
 }
 
-function comparablePackage(value: ServicePackage): string {
-  const copy: Record<string, unknown> = { ...clonePackage(value) };
-  delete copy.package_id;
-  delete copy.source_template_id;
-  delete copy.source_template_version;
-  delete copy.customized_from_template;
-  return JSON.stringify(copy);
-}
-
 function buildPackage(
   values: PackageFormValue,
-  editor: PackageEditor,
+  source: ServicePackage,
 ): ServicePackage {
-  const minimum = values.price_mode === 'negotiable' ? 0 : values.min_price || 0;
-  const maximum =
-    values.price_mode === 'range' ? values.max_price || minimum : minimum;
-  const packageItem: ServicePackage = {
-    ...editor.package,
+  return {
+    ...source,
     name: values.name.trim(),
-    category: values.category.trim(),
     description: values.description.trim(),
-    service_modes: values.service_modes,
-    price_mode: values.price_mode,
-    currency: values.currency.toUpperCase(),
-    price_unit: values.price_unit,
-    min_price: String(minimum),
-    max_price: String(maximum),
+    price_display: values.price_display.trim(),
     duration_minutes: values.duration_minutes,
-    service_radius_km: values.service_modes.includes('onsite')
-      ? values.service_radius_km
-      : null,
-    included_items: values.included_items?.trim() || '',
-    excluded_items: values.excluded_items?.trim() || '',
-    preparation: values.preparation?.trim() || '',
-    out_of_range_policy: values.service_modes.includes('onsite')
-      ? values.out_of_range_policy?.trim() ||
-        '超出服务半径时,请先沟通交通费和是否可以接单。'
-      : '',
+    service_modes: source.service_modes?.length
+      ? source.service_modes
+      : ['at_store', 'onsite'],
     travel_fee: {
-      mode: values.travel_fee_mode,
-      amount: String(values.travel_fee_amount || 0),
-      per_km: String(values.travel_fee_per_km || 0),
+      ...(source.travel_fee || { mode: 'quoted' }),
+      mode: 'quoted',
+      description: values.additional_fee_description.trim(),
     },
-    addons: editor.package.addons || [],
+    service_radius_km: null,
+    out_of_range_policy: '',
+    customized_from_template: source.source_type === 'template',
   };
-  if (packageItem.source_type === 'template' && editor.templatePackage) {
-    packageItem.customized_from_template =
-      comparablePackage(packageItem) !== comparablePackage(editor.templatePackage);
-  } else if (packageItem.source_type === 'template') {
-    packageItem.customized_from_template = true;
-  }
-  return packageItem;
 }
 
 function buildProfile(
@@ -247,35 +140,19 @@ function buildProfile(
   const packages = [...(current?.packages || [])];
   if (editorIndex === null) packages.push(packageItem);
   else packages[editorIndex] = packageItem;
-  const tags = Array.from(
-    new Set(packages.flatMap((item) => item.tags || []).filter(Boolean)),
-  );
-  const allModes = new Set(packages.flatMap((item) => item.service_modes));
   return {
-    headline: values.profile_headline?.trim() || packageItem.name,
-    bio: values.profile_bio?.trim() || packageItem.description,
-    tags,
+    headline: current?.headline || packageItem.name,
+    bio: current?.bio || packageItem.description,
+    tags: current?.tags || [],
     contact_hours:
-      values.contact_hours?.trim() || '请通过 Telegram 私聊确认服务时间',
-    service_modes: Array.from(allModes),
+      current?.contact_hours || '请通过 Telegram 私聊沟通具体服务信息',
+    service_modes: Array.from(
+      new Set(packages.flatMap((item) => item.service_modes || [])),
+    ),
     accepting_requests: current?.accepting_requests ?? true,
     public_area_text: values.public_area_text.trim(),
-    venue: {
-      name:
-        values.venue_name?.trim() ||
-        current?.venue?.name ||
-        (allModes.has('at_store') ? '工作地点' : ''),
-      address_hint:
-        values.venue_address?.trim() || current?.venue?.address_hint || '',
-    },
-    onsite_policy: {
-      description:
-        values.onsite_description?.trim() ||
-        current?.onsite_policy?.description ||
-        (allModes.has('onsite')
-          ? `${packageItem.service_radius_km || 20} 公里内可上门,超区请先沟通。`
-          : ''),
-    },
+    venue: current?.venue || { name: '', address_hint: '' },
+    onsite_policy: current?.onsite_policy || { description: '' },
     packages,
     is_complete: true,
   };
@@ -283,17 +160,13 @@ function buildProfile(
 
 export default function TechnicianMiniApp() {
   const [form] = Form.useForm<PackageFormValue>();
-  const [bootstrap, setBootstrap] = useState<TechnicianMiniAppBootstrap | null>(null);
+  const [bootstrap, setBootstrap] =
+    useState<TechnicianMiniAppBootstrap | null>(null);
   const [editor, setEditor] = useState<PackageEditor | null>(null);
   const [loading, setLoading] = useState(true);
   const [saving, setSaving] = useState(false);
   const [availabilitySaving, setAvailabilitySaving] = useState(false);
   const [pageError, setPageError] = useState('');
-  const serviceModes = Form.useWatch('service_modes', form) || [];
-  const priceMode = Form.useWatch('price_mode', form);
-  const travelMode = Form.useWatch('travel_fee_mode', form);
-  const currency = Form.useWatch('currency', form) || 'CNY';
-  const currencyPrefix = currency === 'CNY' ? '¥' : currency;
 
   const load = async () => {
     setLoading(true);
@@ -318,38 +191,17 @@ export default function TechnicianMiniApp() {
   const packages = context?.service_profile?.packages || [];
   const canAdd = packages.length < (bootstrap?.limits.max_packages || 5);
 
-  const selectedTemplate = useMemo(() => {
-    if (!editor?.package.source_template_id) return undefined;
-    return bootstrap?.templates.find(
-      (item) => item.template_id === editor.package.source_template_id,
-    );
-  }, [bootstrap?.templates, editor?.package.source_template_id]);
-
   const openTemplate = (template: TemplateItem) => {
-    const packageItem = templatePackage(template);
-    setEditor({
-      index: null,
-      package: packageItem,
-      templatePackage: {
-        ...clonePackage(template.package),
-        package_id: packageItem.package_id,
-        source_type: 'template',
-        source_template_id: template.template_id,
-        source_template_version: template.version,
-        customized_from_template: false,
-      },
-    });
-    form.setFieldsValue(
-      editablePackageValues(packageItem, context?.service_profile),
-    );
+    const packageItem = fromTemplate(template);
+    setEditor({ index: null, package: packageItem });
+    form.setFieldsValue(editableValues(packageItem, context?.service_profile));
     window.scrollTo({ top: 0, behavior: 'smooth' });
   };
 
   const openExisting = (packageItem: ServicePackage, index: number) => {
-    setEditor({ index, package: clonePackage(packageItem) });
-    form.setFieldsValue(
-      editablePackageValues(packageItem, context?.service_profile),
-    );
+    const cloned = clonePackage(packageItem);
+    setEditor({ index, package: cloned });
+    form.setFieldsValue(editableValues(cloned, context?.service_profile));
     window.scrollTo({ top: 0, behavior: 'smooth' });
   };
 
@@ -363,7 +215,7 @@ export default function TechnicianMiniApp() {
     if (!editor || !context) return;
     setSaving(true);
     try {
-      const packageItem = buildPackage(values, editor);
+      const packageItem = buildPackage(values, editor.package);
       const profile = buildProfile(values, packageItem, context, editor.index);
       const updated = await technicianApiRequest<TechnicianSelfServiceContext>(
         '/profile',
@@ -372,18 +224,12 @@ export default function TechnicianMiniApp() {
       applyContext(updated);
       setEditor(null);
       form.resetFields();
-      message.success(editor.index === null ? '套餐已发布' : '套餐已更新');
-      window.Telegram?.WebApp?.expand();
+      message.success(editor.index === null ? '服务已发布' : '服务已更新');
       window.scrollTo({ top: 0, behavior: 'smooth' });
     } catch (error) {
       const code = error instanceof TechnicianApiError ? error.code : '';
       const fieldByCode: Record<string, keyof PackageFormValue> = {
-        invalid_price: 'min_price',
-        invalid_price_range: 'max_price',
-        invalid_fixed_price: 'min_price',
         invalid_duration: 'duration_minutes',
-        service_radius_required: 'service_radius_km',
-        invalid_service_radius: 'service_radius_km',
         required_field: 'public_area_text',
       };
       const field = fieldByCode[code];
@@ -410,9 +256,9 @@ export default function TechnicianMiniApp() {
           technicianJsonOptions('PATCH', { accepting_requests: checked }),
         ),
       );
-      message.success(checked ? '已开始接单' : '已暂停接单');
+      message.success(checked ? '已公开展示服务' : '已暂停展示服务');
     } catch (error) {
-      message.error(error instanceof Error ? error.message : '接单状态更新失败');
+      message.error(error instanceof Error ? error.message : '展示状态更新失败');
     } finally {
       setAvailabilitySaving(false);
     }
@@ -421,8 +267,8 @@ export default function TechnicianMiniApp() {
   const deleteCurrentPackage = () => {
     if (!editor || editor.index === null || !context?.service_profile) return;
     Modal.confirm({
-      title: '删除这个套餐?',
-      content: '历史服务单不会受影响。',
+      title: '删除这项服务?',
+      content: '已经发布的历史评价不会受到影响。',
       okText: '删除',
       okButtonProps: { danger: true },
       cancelText: '取消',
@@ -434,14 +280,16 @@ export default function TechnicianMiniApp() {
           technicianJsonOptions('PUT', {
             service_profile: {
               ...profile,
-              packages: profile.packages.filter((_, index) => index !== editor.index),
+              packages: profile.packages.filter(
+                (_, index) => index !== editor.index,
+              ),
             },
           }),
         );
         applyContext(updated);
         setEditor(null);
         form.resetFields();
-        message.success('套餐已删除');
+        message.success('服务已删除');
       },
     });
   };
@@ -461,7 +309,7 @@ export default function TechnicianMiniApp() {
         <Alert
           type="error"
           showIcon
-          message="无法打开技师套餐"
+          message="无法打开技师服务管理"
           description={pageError || '请从 Telegram Bot 重新打开。'}
         />
         <Button size="large" onClick={() => void load()}>
@@ -485,10 +333,10 @@ export default function TechnicianMiniApp() {
           </Button>
           <div>
             <Typography.Title level={1}>
-              {editor.index === null ? '发布新套餐' : '编辑套餐'}
+              {editor.index === null ? '发布服务' : '编辑服务'}
             </Typography.Title>
             <Typography.Text type="secondary">
-              {selectedTemplate ? `已选:${selectedTemplate.name}` : '修改后直接发布'}
+              只需确认核心信息,具体细节私聊沟通
             </Typography.Text>
           </div>
         </header>
@@ -502,265 +350,81 @@ export default function TechnicianMiniApp() {
         >
           <section className={styles.formSection}>
             <div className={styles.sectionTitle}>
-              <span>只确认这些内容</span>
-              <Tag color="green">约 1 分钟</Tag>
+              <span>服务信息</span>
+              <Tag color="green">约 30 秒</Tag>
             </div>
             <Form.Item
               name="name"
-              label="套餐名称"
-              rules={[{ required: true, message: '请填写套餐名称' }]}
+              label="服务名称"
+              rules={[{ required: true, message: '请填写服务名称' }]}
             >
-              <Input size="large" maxLength={50} />
+              <Input size="large" maxLength={50} placeholder="例如:标准上门服务" />
             </Form.Item>
-
-            <Form.Item name="price_mode" label="价格方式">
-              <Radio.Group
-                optionType="button"
-                buttonStyle="solid"
-                options={priceModeOptions}
-                className={styles.choiceGrid}
+            <Form.Item
+              name="price_display"
+              label="服务价格"
+              extra="可填写固定价、起步价、区间或面议。"
+              rules={[{ required: true, message: '请填写服务价格' }]}
+            >
+              <Input size="large" maxLength={80} placeholder="例如:300 元/次起" />
+            </Form.Item>
+            <Form.Item
+              name="description"
+              label="服务内容"
+              rules={[{ required: true, message: '请填写服务内容' }]}
+            >
+              <Input.TextArea
+                rows={4}
+                maxLength={800}
+                showCount
+                placeholder="简单说明这项服务包含什么"
+              />
+            </Form.Item>
+            <Form.Item
+              name="duration_minutes"
+              label="服务时长"
+              rules={[{ required: true, message: '请填写服务时长' }]}
+            >
+              <InputNumber
+                size="large"
+                min={15}
+                max={480}
+                suffix="分钟"
+                inputMode="numeric"
+              />
+            </Form.Item>
+            <Form.Item
+              name="additional_fee_description"
+              label="额外费用说明"
+              extra="例如上门车费、耗材费;没有可填写“无”。"
+              rules={[{ required: true, message: '请填写额外费用说明' }]}
+            >
+              <Input.TextArea
+                rows={2}
+                maxLength={200}
+                placeholder="例如:远距离上门车费另议"
               />
             </Form.Item>
-
-            {priceMode !== 'negotiable' ? (
-              <div className={styles.priceRow}>
-                <Form.Item
-                  name="min_price"
-                  label={priceMode === 'range' ? '最低价' : '服务价格'}
-                  rules={[{ required: true, message: '请填写价格' }]}
-                >
-                  <InputNumber
-                    size="large"
-                    min={0}
-                    precision={2}
-                    prefix={currencyPrefix}
-                    inputMode="decimal"
-                  />
-                </Form.Item>
-                {priceMode === 'range' ? (
-                  <Form.Item
-                    name="max_price"
-                    label="最高价"
-                    dependencies={['min_price']}
-                    rules={[
-                      { required: true, message: '请填写最高价' },
-                      ({ getFieldValue }) => ({
-                        validator(_, value) {
-                          return Number(value) >= Number(getFieldValue('min_price'))
-                            ? Promise.resolve()
-                            : Promise.reject(new Error('最高价不能低于最低价'));
-                        },
-                      }),
-                    ]}
-                  >
-                    <InputNumber
-                      size="large"
-                      min={0}
-                      precision={2}
-                      prefix={currencyPrefix}
-                      inputMode="decimal"
-                    />
-                  </Form.Item>
-                ) : null}
-                <Form.Item name="price_unit" label="计价单位">
-                  <Select size="large" options={priceUnitOptions} />
-                </Form.Item>
-              </div>
-            ) : null}
-
-            <div className={styles.twoColumns}>
-              <Form.Item
-                name="duration_minutes"
-                label="服务时长"
-                rules={[{ required: true, message: '请填写服务时长' }]}
-              >
-                <InputNumber
-                  size="large"
-                  min={15}
-                  max={480}
-                  suffix="分钟"
-                  inputMode="numeric"
-                />
-              </Form.Item>
-              <Form.Item
-                name="service_modes"
-                label="服务方式"
-                rules={[{ required: true, message: '至少选择一种服务方式' }]}
-              >
-                <Checkbox.Group options={modeOptions} className={styles.checkGrid} />
-              </Form.Item>
-            </div>
-
             <Form.Item
               name="public_area_text"
-              label="对外展示的服务区域"
-              extra="例如:洛杉矶市中心及附近。不展示精确地址。"
-              rules={[{ required: true, message: '请填写对外服务区域' }]}
+              label="大概服务区域"
+              extra="只填写城市、区或附近地标,具体地址与顾客私聊。"
+              rules={[{ required: true, message: '请填写大概服务区域' }]}
             >
               <Input
                 size="large"
                 prefix={<MapPin size={18} />}
                 maxLength={80}
-                placeholder="填写顾客能看懂的区域"
+                placeholder="例如:洛杉矶市中心及附近"
               />
             </Form.Item>
-
-            {serviceModes.includes('at_store') ? (
-              <Form.Item
-                name="venue_address"
-                label="到店地址"
-                extra="确认最终报价后才会向顾客展示。"
-                rules={[{ required: true, message: '请填写到店地址' }]}
-              >
-                <Input size="large" maxLength={120} placeholder="街道、门牌号、楼层" />
-              </Form.Item>
-            ) : null}
-
-            {serviceModes.includes('onsite') ? (
-              <Form.Item
-                name="service_radius_km"
-                label="上门范围"
-                rules={[{ required: true, message: '请填写上门范围' }]}
-              >
-                <InputNumber
-                  size="large"
-                  min={0.5}
-                  max={200}
-                  suffix="公里"
-                  inputMode="decimal"
-                />
-              </Form.Item>
+            {editor.index !== null && packages.length > 1 ? (
+              <Button danger block size="large" onClick={deleteCurrentPackage}>
+                删除这项服务
+              </Button>
             ) : null}
           </section>
 
-          <Collapse
-            ghost
-            className={styles.advanced}
-            items={[
-              {
-                key: 'advanced',
-                forceRender: true,
-                label: (
-                  <span className={styles.advancedLabel}>
-                    <SlidersHorizontal size={18} />
-                    更多设置(可跳过)
-                  </span>
-                ),
-                children: (
-                  <>
-                    <Form.Item
-                      name="category"
-                      label="服务分类"
-                      rules={[{ required: true, message: '请填写服务分类' }]}
-                    >
-                      <Input size="large" maxLength={30} />
-                    </Form.Item>
-                    <Form.Item
-                      name="currency"
-                      label="币种"
-                      rules={[
-                        {
-                          pattern: /^[A-Za-z]{3}$/,
-                          message: '请选择三位 ISO 币种',
-                        },
-                      ]}
-                    >
-                      <Select
-                        size="large"
-                        showSearch
-                        options={[
-                          { value: 'CNY', label: 'CNY 人民币' },
-                          { value: 'USD', label: 'USD 美元' },
-                          { value: 'HKD', label: 'HKD 港币' },
-                          { value: 'EUR', label: 'EUR 欧元' },
-                          { value: 'GBP', label: 'GBP 英镑' },
-                          { value: 'JPY', label: 'JPY 日元' },
-                        ]}
-                      />
-                    </Form.Item>
-                    <Form.Item
-                      name="description"
-                      label="套餐说明"
-                      rules={[{ required: true, message: '请填写套餐说明' }]}
-                    >
-                      <Input.TextArea rows={3} maxLength={800} showCount />
-                    </Form.Item>
-                    <Form.Item name="included_items" label="包含内容">
-                      <Input.TextArea rows={2} maxLength={500} />
-                    </Form.Item>
-                    <Form.Item name="excluded_items" label="不包含内容">
-                      <Input.TextArea rows={2} maxLength={500} />
-                    </Form.Item>
-                    <Form.Item name="preparation" label="顾客准备事项">
-                      <Input.TextArea rows={2} maxLength={500} />
-                    </Form.Item>
-                    <Form.Item name="travel_fee_mode" label="交通费">
-                      <Select size="large" options={travelModeOptions} />
-                    </Form.Item>
-                    {travelMode === 'fixed' ? (
-                      <Form.Item name="travel_fee_amount" label="固定交通费">
-                        <InputNumber
-                          size="large"
-                          min={0}
-                          precision={2}
-                          prefix={currencyPrefix}
-                        />
-                      </Form.Item>
-                    ) : null}
-                    {travelMode === 'per_km' ? (
-                      <Form.Item name="travel_fee_per_km" label="每公里交通费">
-                        <InputNumber
-                          size="large"
-                          min={0}
-                          precision={2}
-                          prefix={currencyPrefix}
-                        />
-                      </Form.Item>
-                    ) : null}
-                    {serviceModes.includes('onsite') ? (
-                      <>
-                        <Form.Item name="out_of_range_policy" label="超区说明">
-                          <Input.TextArea rows={2} maxLength={200} />
-                        </Form.Item>
-                        <Form.Item name="onsite_description" label="上门服务说明">
-                          <Input.TextArea rows={2} maxLength={300} />
-                        </Form.Item>
-                      </>
-                    ) : null}
-                    {serviceModes.includes('at_store') ? (
-                      <Form.Item name="venue_name" label="到店地点名称">
-                        <Input size="large" maxLength={80} />
-                      </Form.Item>
-                    ) : null}
-                    <Form.Item name="profile_headline" label="技师服务标题">
-                      <Input size="large" maxLength={80} />
-                    </Form.Item>
-                    <Form.Item name="profile_bio" label="技师服务介绍">
-                      <Input.TextArea rows={3} maxLength={800} />
-                    </Form.Item>
-                    <Form.Item name="contact_hours" label="可联系时段">
-                      <Input
-                        size="large"
-                        maxLength={120}
-                        prefix={<Clock3 size={18} />}
-                      />
-                    </Form.Item>
-                    {editor.index !== null && packages.length > 1 ? (
-                      <Button
-                        danger
-                        block
-                        size="large"
-                        onClick={deleteCurrentPackage}
-                      >
-                        删除这个套餐
-                      </Button>
-                    ) : null}
-                  </>
-                ),
-              },
-            ]}
-          />
-
           <div className={styles.stickyAction}>
             <Button
               htmlType="submit"
@@ -770,7 +434,7 @@ export default function TechnicianMiniApp() {
               loading={saving}
               icon={!saving ? <Check size={20} /> : undefined}
             >
-              {editor.index === null ? '确认并发布套餐' : '保存修改'}
+              {editor.index === null ? '直接发布' : '保存修改'}
             </Button>
           </div>
         </Form>
@@ -785,12 +449,12 @@ export default function TechnicianMiniApp() {
           <Typography.Text type="secondary">技师自助中心</Typography.Text>
           <Typography.Title level={1}>你好,{context.display_name}</Typography.Title>
           <Typography.Paragraph>
-            选择模板后只需确认价格、时长和服务区域。
+            选择模板,确认价格、内容、时长、额外费用和区域即可。
           </Typography.Paragraph>
         </div>
         {context.service_profile ? (
           <div className={styles.availability}>
-            <span>接单</span>
+            <span>公开展示</span>
             <Switch
               checked={context.service_profile.accepting_requests}
               loading={availabilitySaving}
@@ -817,11 +481,25 @@ export default function TechnicianMiniApp() {
         />
       ) : null}
 
+      {context.review_topic?.topic_url ? (
+        <section className={styles.homeSection}>
+          <Button
+            block
+            size="large"
+            icon={<ExternalLink size={18} />}
+            href={context.review_topic.topic_url}
+            target="_blank"
+          >
+            查看我的公开评价话题
+          </Button>
+        </section>
+      ) : null}
+
       {packages.length ? (
         <section className={styles.homeSection}>
           <div className={styles.homeSectionHeading}>
             <div>
-              <Typography.Title level={2}>我的套餐</Typography.Title>
+              <Typography.Title level={2}>我的服务</Typography.Title>
               <Typography.Text type="secondary">
                 已发布 {packages.length}/{bootstrap.limits.max_packages}
               </Typography.Text>
@@ -854,10 +532,10 @@ export default function TechnicianMiniApp() {
           <div className={styles.homeSectionHeading}>
             <div>
               <Typography.Title level={2}>
-                {packages.length ? '新增套餐' : '选一个模板开始'}
+                {packages.length ? '新增服务' : '选择一个模板'}
               </Typography.Title>
               <Typography.Text type="secondary">
-                所有内容都能修改,后台模板不会覆盖已发布套餐。
+                模板只用于预填,选择后仍可修改。
               </Typography.Text>
             </div>
           </div>
@@ -888,7 +566,7 @@ export default function TechnicianMiniApp() {
               ))}
             </div>
           ) : (
-            <Empty description="后台暂未启用套餐模板" />
+            <Empty description="后台暂未启用服务模板" />
           )}
         </section>
       ) : null}

+ 23 - 2
admin-web/src/types.ts

@@ -401,6 +401,12 @@ export interface TechnicianSelfServiceContext {
     issues: Array<{ code: string; message: string }>;
   };
   service_profile?: TechnicianServiceProfile | null;
+  review_topic?: {
+    message_thread_id: number;
+    topic_name: string;
+    topic_url: string;
+    status: 'active';
+  } | null;
 }
 
 export interface TechnicianMiniAppBootstrap {
@@ -456,19 +462,24 @@ export interface ServiceOrder {
 
 export interface ReviewQuestion {
   question_id: string;
+  type: 'single_choice' | 'multiple_choice' | 'text';
   label: string;
   description?: string;
   required: boolean;
   weight?: number;
   max_length?: number;
+  options?: Array<{
+    value: string;
+    label: string;
+    score?: number;
+  }>;
 }
 
 export interface ServiceReviewTemplate {
   template_id: string;
   version: number;
   status: 'active' | 'retired';
-  rating_questions: ReviewQuestion[];
-  text_questions: ReviewQuestion[];
+  questions: ReviewQuestion[];
   published_at: string;
 }
 
@@ -484,7 +495,15 @@ export interface ServiceReview {
   score: number;
   anonymous: boolean;
   status: 'pending' | 'approved' | 'rejected' | 'voided';
+  choice_answers: Record<string, string | string[]>;
   text_answers: Record<string, string>;
+  topic_url?: string;
+  topic_message_id?: number;
+  topic_publication?: {
+    status: 'published' | 'unconfigured' | 'failed';
+    topic_url?: string;
+    message?: string;
+  };
   customer_review_count: number;
   customer_review_count_30d: number;
   customer_review_count_90d: number;
@@ -542,6 +561,8 @@ export interface ServiceSettings {
   customer_max_open_orders: number;
   customer_daily_request_limit: number;
   review_qr_expiry_hours: number;
+  review_forum_chat_id: string;
+  review_forum_username: string;
 }
 
 export interface FulfillmentMetrics {

+ 16 - 5
admin-web/tests/e2e/admin.spec.ts

@@ -230,6 +230,8 @@ test.beforeEach(async ({ page }) => {
         customer_max_open_orders: 3,
         customer_daily_request_limit: 10,
         review_qr_expiry_hours: 24,
+        review_forum_chat_id: '-1001234567890',
+        review_forum_username: 'public_reviews',
       };
     } else if (path.endsWith('/directory/package-templates')) {
       data = { items: [], total: 0 };
@@ -248,10 +250,19 @@ test.beforeEach(async ({ page }) => {
         version: 1,
         status: 'active',
         published_at: '2026-07-26T00:00:00Z',
-        rating_questions: [
-          { question_id: 'effect', label: '服务效果', required: true, weight: 100 },
+        questions: [
+          {
+            question_id: 'effect',
+            type: 'single_choice',
+            label: '服务效果',
+            required: true,
+            weight: 100,
+            options: [
+              { value: 'good', label: '满意', score: 5 },
+              { value: 'bad', label: '不满意', score: 1 },
+            ],
+          },
         ],
-        text_questions: [],
       };
     } else if (path.endsWith('/settings')) {
       data = {
@@ -302,7 +313,7 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
   for (const [path, heading] of [
     ['/admin/points', '积分管理'],
     ['/admin/directory', '技师与附近'],
-    ['/admin/services', '技师服务'],
+    ['/admin/services', '技师咨询与评价'],
     ['/admin/audit', '操作日志'],
     ['/admin/settings', '系统设置'],
   ] as const) {
@@ -318,7 +329,7 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
       await page.screenshot({ path: testInfo.outputPath('directory.png'), fullPage: true });
     }
     if (path === '/admin/services') {
-      await expect(page.getByText('最小履约闭环')).toBeVisible();
+      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);

+ 15 - 7
admin-web/tests/e2e/technician-mini-app.spec.ts

@@ -17,7 +17,12 @@ const packageTemplate = {
   excluded_items: '额外耗材',
   preparation: '请提前说明需求',
   addons: [],
-  travel_fee: { mode: 'included', amount: '0', per_km: '0' },
+  travel_fee: {
+    mode: 'included',
+    amount: '0',
+    per_km: '0',
+    description: '无额外费用',
+  },
   service_radius_km: null,
   out_of_range_policy: '',
   source_type: 'template',
@@ -79,17 +84,20 @@ test.beforeEach(async ({ page }) => {
   });
 });
 
-test('技师可从模板快速进入一分钟发布表单', async ({ page }) => {
+test('技师可从模板快速进入五项发布表单', async ({ page }) => {
   await page.goto('/admin/technician?bot_id=primary');
 
   await expect(page.getByRole('heading', { name: '你好,测试技师' })).toBeVisible();
   await expect(page.getByRole('button', { name: /快速到店服务/ })).toBeVisible();
   await page.getByRole('button', { name: /快速到店服务/ }).click();
 
-  await expect(page.getByRole('heading', { name: '发布新套餐' })).toBeVisible();
-  await expect(page.getByText('约 1 分钟')).toBeVisible();
-  await expect(page.getByLabel('套餐名称')).toHaveValue('标准到店服务');
-  await expect(page.getByRole('radio', { name: '固定价' })).toBeChecked();
+  await expect(page.getByRole('heading', { name: '发布服务' })).toBeVisible();
+  await expect(page.getByText('约 30 秒')).toBeVisible();
+  await expect(page.getByLabel('服务名称')).toHaveValue('标准到店服务');
+  await expect(page.getByLabel('服务价格')).toHaveValue('CNY 200.00/次');
+  await expect(page.getByLabel('服务内容')).toHaveValue('顾客到店完成一次标准服务。');
+  await expect(page.getByLabel('额外费用说明')).toHaveValue('无额外费用');
+  await expect(page.getByLabel('大概服务区域')).toBeVisible();
   await expect(page.getByText('per_service')).toHaveCount(0);
-  await expect(page.getByRole('button', { name: /确认并发布套餐/ })).toBeVisible();
+  await expect(page.getByRole('button', { name: /直接发布/ })).toBeVisible();
 });

+ 26 - 10
admin-web/tests/unit/Services.test.tsx

@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
 import { beforeEach, expect, test, vi } from 'vitest';
 
 import ServicesPage from '@/pages/Services';
@@ -34,6 +34,8 @@ beforeEach(() => {
         customer_max_open_orders: 3,
         customer_daily_request_limit: 10,
         review_qr_expiry_hours: 24,
+        review_forum_chat_id: '-1001234567890',
+        review_forum_username: 'public_reviews',
       };
     }
     if (path.startsWith('/directory/package-templates')) {
@@ -59,8 +61,10 @@ beforeEach(() => {
               price_unit: 'per_service',
               min_price: '100.00',
               max_price: '100.00',
+              price_display: '100 元/次',
+              duration_minutes: 60,
               addons: [],
-              travel_fee: { mode: 'quoted' },
+              travel_fee: { mode: 'quoted', description: '无额外费用' },
             },
           },
         ],
@@ -79,28 +83,40 @@ beforeEach(() => {
         version: 1,
         status: 'active',
         published_at: '2026-07-26T00:00:00Z',
-        rating_questions: [
-          { question_id: 'effect', label: '服务效果', required: true, weight: 100 },
+        questions: [
+          {
+            question_id: 'effect',
+            type: 'single_choice',
+            label: '服务效果',
+            required: true,
+            weight: 100,
+            options: [
+              { value: 'good', label: '满意', score: 5 },
+              { value: 'bad', label: '不满意', score: 1 },
+            ],
+          },
         ],
-        text_questions: [],
       };
     }
     throw new Error(`Unexpected request: ${path}`);
   });
 });
 
-test('展示履约漏斗、统计边界和套餐模板', async () => {
+test('展示咨询评价说明、公开群设置和服务模板', async () => {
   render(<ServicesPage />);
 
-  expect(screen.getByText('技师服务')).toBeInTheDocument();
-  expect(screen.getByText('最小履约闭环')).toBeInTheDocument();
+  expect(screen.getByText('技师咨询与评价')).toBeInTheDocument();
+  expect(screen.getByText('咨询平台模式')).toBeInTheDocument();
   expect(await screen.findByText('12')).toBeInTheDocument();
+  await waitFor(() => {
+    expect(screen.getByLabelText('公开群 Chat ID')).toHaveValue('-1001234567890');
+  });
   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();
+  expect(await screen.findByLabelText('默认服务价格')).toBeInTheDocument();
+  expect(screen.getByLabelText('默认额外费用说明')).toBeInTheDocument();
 });

+ 13 - 10
admin-web/tests/unit/TechnicianMiniApp.test.tsx

@@ -37,7 +37,12 @@ const packageTemplate = {
   excluded_items: '',
   preparation: '',
   addons: [],
-  travel_fee: { mode: 'included' as const, amount: '0', per_km: '0' },
+  travel_fee: {
+    mode: 'included' as const,
+    amount: '0',
+    per_km: '0',
+    description: '无额外费用',
+  },
   service_radius_km: null,
   out_of_range_policy: '',
   source_type: 'template' as const,
@@ -88,24 +93,22 @@ beforeEach(() => {
   });
 });
 
-test('模板创建只要求技师确认常用中文字段并可直接发布', async () => {
+test('模板创建只要求技师确认五项信息并可直接发布', async () => {
   render(<TechnicianMiniApp />);
 
   expect(await screen.findByText('你好,测试技师')).toBeInTheDocument();
   fireEvent.click(screen.getByRole('button', { name: /快速到店服务/ }));
 
-  expect(await screen.findByText('发布新套餐')).toBeInTheDocument();
-  expect(screen.getByRole('radio', { name: '固定价' })).toBeChecked();
-  expect(screen.getByText('每次')).toBeInTheDocument();
+  expect(await screen.findByText('发布服务')).toBeInTheDocument();
+  expect(screen.getByLabelText('服务价格')).toHaveValue('CNY 200.00/次');
+  expect(screen.getByLabelText('服务内容')).toHaveValue('一次标准到店服务');
+  expect(screen.getByLabelText('额外费用说明')).toHaveValue('无额外费用');
   expect(screen.queryByText('per_service')).not.toBeInTheDocument();
 
-  fireEvent.change(screen.getByLabelText('对外展示的服务区域'), {
+  fireEvent.change(screen.getByLabelText('大概服务区域'), {
     target: { value: '测试城区及附近' },
   });
-  fireEvent.change(screen.getByLabelText('到店地址'), {
-    target: { value: '测试路 100 号' },
-  });
-  fireEvent.click(screen.getByRole('button', { name: /确认并发布套餐/ }));
+  fireEvent.click(screen.getByRole('button', { name: /直接发布/ }));
 
   await waitFor(() => {
     expect(technicianApiRequest).toHaveBeenCalledWith(

+ 2 - 0
config.env.template

@@ -41,6 +41,8 @@ ADMIN_WEB_STATIC_DIR=admin-web/dist
 SERVICE_ADDRESS_ENCRYPTION_KEY=
 SERVICE_ADDRESS_RETENTION_DAYS=7
 SERVICE_REVIEW_QR_EXPIRY_HOURS=24
+SERVICE_REVIEW_FORUM_CHAT_ID=
+SERVICE_REVIEW_FORUM_USERNAME=
 SERVICE_CUSTOMER_MAX_OPEN_ORDERS=3
 SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT=10
 SERVICE_TECHNICIAN_MINI_APP_URL=https://tg.97admin.com/admin/technician

+ 2 - 0
sample_config.env

@@ -34,4 +34,6 @@ PM_PERMIT=1 # fill 0 to disable it
 
 # Technician Telegram Mini App.
 SERVICE_TECHNICIAN_MINI_APP_URL=https://tg.97admin.com/admin/technician
+SERVICE_REVIEW_FORUM_CHAT_ID=
+SERVICE_REVIEW_FORUM_USERNAME=
 SERVICE_MINI_APP_AUTH_MAX_AGE_SECONDS=3600

+ 4 - 0
sample_config.py

@@ -59,6 +59,10 @@ SERVICE_ADDRESS_ENCRYPTION_KEY = os.environ.get(
 )
 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_REVIEW_FORUM_CHAT_ID = os.environ.get("SERVICE_REVIEW_FORUM_CHAT_ID", "")
+SERVICE_REVIEW_FORUM_USERNAME = os.environ.get(
+    "SERVICE_REVIEW_FORUM_USERNAME", ""
+)
 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

+ 45 - 3
tests/test_directory.py

@@ -155,6 +155,46 @@ async def test_location_only_keeps_coordinates_and_source(app_modules):
     assert "coordinate_key" not in updated
 
 
+async def test_clearing_legacy_coordinates_keeps_public_teacher_state(app_modules):
+    directory_db = app_modules.load("wbb.utils.dbdirectory")
+    await directory_db.upsert_directory_identity(
+        user_id=350,
+        username="teacher350",
+        first_name="公开",
+        last_name="技师",
+    )
+    await directory_db.save_directory_location(
+        user_id=350,
+        longitude=121.48,
+        latitude=31.24,
+        source="test",
+        actor_id=350,
+        actor_name="公开技师",
+    )
+    await app_modules.wbb.control_db.directory_profiles.update_one(
+        {"user_id": 350},
+        {
+            "$set": {
+                "listed": True,
+                "presence_status": "online",
+                "online_until": directory_db.utc_now() + timedelta(hours=1),
+            }
+        },
+    )
+
+    assert await directory_db.clear_directory_location(
+        user_id=350,
+        actor_id=350,
+        actor_name="公开技师",
+        source="test",
+        reason="不再保存精确坐标",
+    )
+    profile = await directory_db.get_directory_profile(350)
+    assert profile["listed"] is True
+    assert profile["presence_status"] == "online"
+    assert profile["online_until"] is not None
+
+
 async def test_cross_bot_membership_verification_uses_live_status(app_modules):
     directory_db = app_modules.load("wbb.utils.dbdirectory")
     directory_service = app_modules.load("wbb.services.directory")
@@ -292,9 +332,10 @@ async def test_role_specific_bottom_menus_are_chinese(app_modules):
         student_labels = [
             button.text for row in student.keyboard for button in row
         ]
-        assert "Telegram 定位" in student_labels
-        assert "手动选择位置" in student_labels
-        assert "附近技师" in student_labels
+        assert "浏览技师" in student_labels
+        assert "Telegram 定位" not in student_labels
+        assert "手动选择位置" not in student_labels
+        assert "清除位置" not in student_labels
         assert "申请成为技师" in student_labels
         assert "我的状态" not in student_labels
         assert "上线" not in student_labels
@@ -324,6 +365,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
 

+ 135 - 26
tests/test_service_fulfillment.py

@@ -209,9 +209,8 @@ async def test_public_customer_order_quote_address_and_completion_qr(app_modules
         technician_id=profile["user_id"],
         source="qr_verified",
         answers={
-            "service_effect": 5,
-            "professionalism": 5,
-            "communication": 5,
+            "overall_experience": "excellent",
+            "service_highlights": ["professional", "communication"],
             "comment": "已完成服务单评价",
         },
         anonymous=False,
@@ -227,9 +226,8 @@ async def test_public_customer_order_quote_address_and_completion_qr(app_modules
             technician_id=profile["user_id"],
             source="qr_verified",
             answers={
-                "service_effect": 5,
-                "professionalism": 5,
-                "communication": 5,
+                "overall_experience": "excellent",
+                "service_highlights": ["professional"],
             },
             anonymous=True,
             qr_id=claimed["qr_id"],
@@ -377,73 +375,98 @@ async def test_reviews_same_weight_anonymous_and_bayesian_leaderboard(app_module
     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": "服务很专业"}
+    answers = {
+        "overall_experience": "excellent",
+        "service_highlights": ["professional", "communication"],
+        "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,
+        answers={"overall_experience": "excellent"},
+        question_index=1,
     )
     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}
+    assert resumed_draft["answers"] == {"overall_experience": "excellent"}
     await service_db.delete_review_draft(customer.id)
 
     review_ids = []
-    for _ in range(3):
+    for _ in range(1):
         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
+        assert review["package_snapshot"]["name"] == "技师服务"
+        assert review["category"] == "护理"
+    assert len(set(review_ids)) == 1
     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 total == 1
     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
+    assert summary["review_count"] == 1
 
     pending, _ = await service_db.list_reviews(
         status="approved", source="student_initiated"
     )
-    assert all(item["similar_content_count"] == 3 for item in pending)
+    assert all(item["similar_content_count"] == 1 for item in pending)
 
     leaderboard, total = await service_db.list_leaderboard(category="护理")
     assert total == 1
-    assert leaderboard[0]["review_count"] == 3
+    assert leaderboard[0]["review_count"] == 1
     assert leaderboard[0]["eligible"] is True
     assert leaderboard[0]["rank_score"] == pytest.approx(
         leaderboard[0]["average_score"]
     )
 
 
+async def test_direct_review_skips_package_selection(app_modules, monkeypatch):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    service_module = app_modules.load("wbb.modules.technician_services")
+    profile = await _technician(app_modules, service_db)
+    customer = await _accepted_customer(service_db)
+    captured = {}
+
+    async def fake_begin_review(target, **values):
+        captured.update(values)
+        captured["customer_id"] = target.from_user.id
+
+    monkeypatch.setattr(service_module, "_begin_review", fake_begin_review)
+    await service_module._choose_review_package(
+        SimpleNamespace(from_user=customer),
+        profile["user_id"],
+    )
+
+    assert captured == {
+        "customer_id": customer.id,
+        "technician_id": profile["user_id"],
+        "source": "student_initiated",
+    }
+
+
 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)
@@ -454,9 +477,8 @@ async def test_review_moderation_only_allows_defined_state_transitions(app_modul
         technician_id=profile["user_id"],
         source="student_initiated",
         answers={
-            "service_effect": 5,
-            "professionalism": 5,
-            "communication": 5,
+            "overall_experience": "excellent",
+            "service_highlights": ["professional"],
         },
         anonymous=True,
         package_id=package_id,
@@ -479,6 +501,67 @@ async def test_review_moderation_only_allows_defined_state_transitions(app_modul
         )
 
 
+async def test_first_approved_review_creates_topic_and_is_published_once(
+    app_modules,
+    monkeypatch,
+):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    review_service = app_modules.load("wbb.services.technician_reviews")
+    profile = await _technician(app_modules, service_db)
+    customer = await _accepted_customer(service_db)
+    package_id = profile["service_profile"]["packages"][0]["package_id"]
+    await service_db.set_service_settings(
+        {
+            "review_forum_chat_id": "-1001234567890",
+            "review_forum_username": "public_reviews",
+        }
+    )
+    calls = []
+
+    async def fake_bot_api(_token, method, payload):
+        calls.append((method, payload))
+        if method == "createForumTopic":
+            return {"message_thread_id": 321}
+        return {"message_id": 900 + len(calls)}
+
+    monkeypatch.setattr(review_service, "_bot_api", fake_bot_api)
+    review = await service_db.submit_review(
+        customer=customer,
+        technician_id=profile["user_id"],
+        source="student_initiated",
+        answers={
+            "overall_experience": "excellent",
+            "service_highlights": ["professional"],
+            "comment": "服务很专业",
+        },
+        anonymous=True,
+        package_id=package_id,
+    )
+    approved = await service_db.moderate_review(
+        review["review_id"],
+        "approve",
+        actor_id="admin",
+    )
+    published = await review_service.publish_approved_review(
+        "123456:test-token",
+        approved,
+    )
+    assert published["status"] == "published"
+    assert published["topic_url"] == "https://t.me/public_reviews/321"
+    assert [method for method, _ in calls] == [
+        "createForumTopic",
+        "sendMessage",
+        "sendMessage",
+    ]
+
+    repeated = await review_service.publish_approved_review(
+        "123456:test-token",
+        approved,
+    )
+    assert repeated["status"] == "published"
+    assert len(calls) == 3
+
+
 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)
@@ -490,6 +573,25 @@ async def test_technician_requires_active_managed_group_membership(app_modules):
         await _order(service_db, customer, profile)
 
 
+async def test_technician_uses_public_area_without_precise_location(app_modules):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    profile = await _technician(app_modules, service_db)
+    await app_modules.wbb.control_db.directory_locations.delete_one(
+        {"user_id": profile["user_id"]}
+    )
+    republished = await service_db.publish_technician_profile(
+        profile["user_id"],
+        profile["service_profile"],
+        actor_id=profile["user_id"],
+    )
+    assert republished["service_profile"]["public_area_text"] == "北京朝阳区附近"
+
+    technicians, total = await service_db.list_available_technicians()
+    assert total == 1
+    assert technicians[0]["public_area_text"] == "北京朝阳区附近"
+    assert technicians[0]["distance_band"] == ""
+
+
 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"}
@@ -556,9 +658,16 @@ async def test_service_admin_api_settings_templates_and_permissions(app_modules)
             "/api/admin/v1/directory/review-template"
         )
         assert review_template.status == 200
+        template_data = (await review_template.json())["data"]
+        assert {item["type"] for item in template_data["questions"]} == {
+            "single_choice",
+            "multiple_choice",
+            "text",
+        }
         assert sum(
-            item["weight"]
-            for item in (await review_template.json())["data"]["rating_questions"]
+            item.get("weight", 0)
+            for item in template_data["questions"]
+            if item["type"] == "single_choice"
         ) == 100
 
         metrics = await client.get(

+ 0 - 8
tests/test_technician_mini_app.py

@@ -54,14 +54,6 @@ async def _approved_technician(app_modules, user_id: int) -> None:
         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,

+ 11 - 9
wbb/__main__.py

@@ -100,15 +100,11 @@ BOT_COMMANDS = [
     BotCommand("gcancel", "取消抽奖并退款"),
     BotCommand("greroll", "重新抽取中奖者"),
     BotCommand("rules", "查看本群群规"),
-    BotCommand("directory", "打开附近技师菜单"),
-    BotCommand("nearby", "查找附近技师"),
-    BotCommand("teacher_rank", "查看技师榜单"),
-    BotCommand("find_technician", "查找附近技师"),
-    BotCommand("my_services", "查看我的服务单"),
+    BotCommand("directory", "打开技师咨询与评价"),
+    BotCommand("find_technician", "浏览并咨询技师"),
     BotCommand("technician_profile", "管理技师服务资料"),
     BotCommand("review_teacher", "主动评价技师"),
     BotCommand("technician_top", "查看审核评价排行"),
-    BotCommand("review_qr", "生成完成评价二维码"),
 ]
 BOT_COMMAND_PERMISSIONS = {
     "manage": "__group_any__",
@@ -282,8 +278,8 @@ if has_any_permission(GROUP_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS):
 if has_permission("teacher_directory.manage", BOT_PERMISSIONS):
     home_actions.append(
         InlineKeyboardButton(
-            text="附近技师",
-            url=f"https://t.me/{BOT_USERNAME}?start=directory",
+            text="技师咨询与评价",
+            url=f"https://t.me/{BOT_USERNAME}?start=services",
         )
     )
 
@@ -319,7 +315,7 @@ assigned_features = [
         ("points.manage", "积分"),
         ("giveaways.manage", "抽奖"),
         ("karma.manage", "声望"),
-        ("teacher_directory.manage", "技师与附近"),
+        ("teacher_directory.manage", "技师咨询与评价"),
     )
     if has_permission(permission, BOT_PERMISSIONS)
 ]
@@ -431,6 +427,12 @@ async def start(_, message):
             from wbb.modules.teacher_directory import send_directory_menu
 
             return await send_directory_menu(message)
+        if name == "services" and has_permission(
+            "teacher_directory.manage", BOT_PERMISSIONS
+        ):
+            from wbb.modules.technician_services import send_service_home
+
+            return await send_service_home(message)
         if name == "mkdwn_help":
             await message.reply(
                 MARKDOWN,

+ 46 - 2
wbb/admin/api.py

@@ -70,6 +70,10 @@ from wbb.services.giveaways import (
     reroll_giveaway,
 )
 from wbb.services.point_settings import apply_point_rules
+from wbb.services.technician_reviews import (
+    ensure_technician_review_topic,
+    publish_approved_review,
+)
 from wbb.utils.dbadmin import (
     create_admin_session,
     dashboard_counts,
@@ -426,6 +430,7 @@ async def technician_authentication_middleware(request: web.Request, handler):
     except TelegramWebAppAuthError as exc:
         raise ApiProblem("mini_app_auth_failed", str(exc), status=401) from exc
     request["technician"] = {**identity, "bot_id": bot_id}
+    request["telegram_bot_token"] = str(profile.get("bot_token") or "")
     return await handler(request)
 
 
@@ -480,6 +485,28 @@ def _selected_bot_id(request: web.Request) -> str:
     )
 
 
+def _request_bot_token(request: web.Request) -> str:
+    direct = str(request.get("telegram_bot_token") or "").strip()
+    if direct:
+        return direct
+    if bool(getattr(wbb, "SUPERVISOR_MODE", False)):
+        try:
+            profile = get_bot_profile_secrets(
+                getattr(wbb, "BOT_PROFILES_PATH", "runtime/bot_profiles.json"),
+                _selected_bot_id(request),
+            )
+        except (ApiProblem, BotConfigError):
+            profile = {}
+        token = str(profile.get("bot_token") or "").strip()
+        if token:
+            return token
+    return str(
+        getattr(telegram_app, "bot_token", None)
+        or getattr(wbb, "BOT_TOKEN", "")
+        or ""
+    ).strip()
+
+
 def _require_internal_request(request: web.Request) -> None:
     supervisor = getattr(wbb, "BOT_SUPERVISOR", None)
     expected = str(
@@ -1190,7 +1217,7 @@ class AdminApi:
         body = await json_body(request)
         require_confirmation(body)
         saved = await set_service_settings(body)
-        set_audit(request, "service.settings.update", summary="更新履约服务设置")
+        set_audit(request, "service.settings.update", summary="更新技师咨询与评价设置")
         return success(saved)
 
     async def service_package_templates(self, request: web.Request) -> web.Response:
@@ -1278,6 +1305,11 @@ class AdminApi:
             target_id=user_id,
             summary="更新技师服务资料",
         )
+        with suppress(Exception):
+            await ensure_technician_review_topic(
+                _request_bot_token(request),
+                user_id,
+            )
         return success(updated)
 
     async def technician_mini_app_bootstrap(
@@ -1326,6 +1358,11 @@ class AdminApi:
             target_id=identity["user_id"],
             summary="技师通过 Mini App 更新服务资料和套餐",
         )
+        with suppress(Exception):
+            await ensure_technician_review_topic(
+                _request_bot_token(request),
+                int(identity["user_id"]),
+            )
         return success(
             await get_technician_self_service_context(updated["user_id"])
         )
@@ -1538,12 +1575,19 @@ class AdminApi:
             actor_id=request["admin"]["username"],
             reason=reason,
         )
+        publication = None
+        if action == "approve":
+            publication = await publish_approved_review(
+                _request_bot_token(request),
+                updated,
+            )
+            updated = {**updated, "topic_publication": publication}
         set_audit(
             request,
             f"service.review.{action}",
             target_id=request.match_info["review_id"],
             summary=f"评价审核:{action}",
-            metadata={"reason": reason},
+            metadata={"reason": reason, "topic_publication": publication},
         )
         with suppress(Exception):
             await telegram_app.send_message(

+ 58 - 114
wbb/modules/teacher_directory.py

@@ -32,11 +32,9 @@ from wbb.services.directory import (
     observe_group_member,
     profile_summary,
     public_teacher,
-    require_platform_membership,
     require_teacher_admin,
     start_presence_sweeper,
     status_value,
-    update_user_location,
     verify_platform_membership,
 )
 from wbb.services.message_cleanup import schedule_group_cleanup
@@ -45,16 +43,15 @@ from wbb.utils.dbdirectory import (
     APPLICATION_PENDING,
     APPLICATION_REJECTED,
     APPLICATION_REVOKED,
-    get_directory_location,
     get_directory_profile,
     list_directory_profiles,
     list_teacher_applications,
 )
 
-__MODULE__ = "技师与附近"
-__HELP__ = """/directory - 打开附近技师菜单。
-/nearby - 选择范围并查找附近技师。
-/teacher_rank - 按距离查看全部上榜技师。
+__MODULE__ = "技师咨询与评价"
+__HELP__ = """/directory - 打开技师咨询与评价菜单。
+/nearby - 浏览公开技师(兼容命令)。
+/teacher_rank - 浏览公开技师(兼容命令)。
 /teacher_apply - 申请成为技师。
 /teacher_online - 技师上线。
 /teacher_offline - 技师下线。
@@ -76,6 +73,7 @@ TEACHER_ACTION_LABELS = {
 }
 BUTTON_NEARBY = "附近技师"
 BUTTON_RANK = "技师榜单"
+BUTTON_BROWSE = "浏览技师"
 BUTTON_APPLY = "申请成为技师"
 BUTTON_APPROVAL = "技师审批"
 BUTTON_STATUS = "我的状态"
@@ -83,13 +81,10 @@ BUTTON_LIST = "上榜"
 BUTTON_UNLIST = "下榜"
 BUTTON_ONLINE = "🟢 上线"
 BUTTON_OFFLINE = "🔴 下线"
-BUTTON_TELEGRAM_LOCATION = "Telegram 定位"
-BUTTON_MANUAL_LOCATION = "手动选择位置"
 BUTTON_CLEAR_LOCATION = "清除位置"
 
 _admin_flows: dict[int, dict[str, Any]] = {}
 _admin_confirmations: dict[str, dict[str, Any]] = {}
-_location_flows: dict[int, datetime] = {}
 _source_chat_contexts: dict[int, dict[str, Any]] = {}
 
 
@@ -104,9 +99,7 @@ def _directory_keyboard(
 ) -> ReplyKeyboardMarkup:
     status = str((profile or {}).get("application_status") or "none")
     rows: list[list[KeyboardButton]] = [
-        [KeyboardButton(BUTTON_TELEGRAM_LOCATION, request_location=True)],
-        [KeyboardButton(BUTTON_MANUAL_LOCATION)],
-        [KeyboardButton(BUTTON_NEARBY), KeyboardButton(BUTTON_RANK)],
+        [KeyboardButton(BUTTON_BROWSE)],
     ]
     if status == APPLICATION_APPROVED:
         rows.extend(
@@ -134,7 +127,6 @@ def _directory_keyboard(
                 )
             ]
         )
-    rows.append([KeyboardButton(BUTTON_CLEAR_LOCATION)])
     if is_admin:
         rows.append([KeyboardButton(BUTTON_APPROVAL)])
     return ReplyKeyboardMarkup(
@@ -142,7 +134,7 @@ def _directory_keyboard(
         resize_keyboard=True,
         is_persistent=True,
         selective=True,
-        placeholder="选择附近技师服务或输入消息",
+        placeholder="浏览技师、咨询或评价",
     )
 
 
@@ -156,11 +148,9 @@ def _group_directory_keyboard(
     rows: list[list[InlineKeyboardButton]] = [
         [
             InlineKeyboardButton(
-                "更新位置",
-                url=f"https://t.me/{BOT_USERNAME}?start=directory_{chat_id}",
-            ),
-            InlineKeyboardButton(BUTTON_NEARBY, callback_data="dir:radius"),
-            InlineKeyboardButton(BUTTON_RANK, callback_data="dir:list:all:1"),
+                BUTTON_BROWSE,
+                url=f"https://t.me/{BOT_USERNAME}?start=services",
+            )
         ]
     ]
     if status == APPLICATION_APPROVED:
@@ -206,8 +196,9 @@ def _presence_label(online: bool) -> str:
 
 def _menu_text(summary: dict[str, Any]) -> str:
     lines = [
-        "<b>附近技师</b>",
-        f"位置:{'已更新' if summary['has_location'] else '尚未更新'}",
+        "<b>技师服务咨询与评价</b>",
+        "顾客可浏览服务、直接私聊咨询,并在服务后评价技师。",
+        "具体地点由双方私聊沟通,平台仅展示技师填写的大概服务区域。",
     ]
     status = summary["application_status"]
     if status == APPLICATION_APPROVED:
@@ -265,18 +256,12 @@ async def send_directory_menu(
             }
         except Exception:
             return await message.reply_text("暂时无法核验来源群身份,请稍后重试。")
-    try:
-        await require_platform_membership(int(message.from_user.id))
-    except DirectoryServiceError as exc:
-        await message.reply_text(str(exc))
-        return
     profile = await observe_directory_user(message.from_user)
-    location = await get_directory_location(int(message.from_user.id))
     admin_result = await verify_platform_membership(
         user_id=int(message.from_user.id),
         require_admin=True,
     )
-    summary = profile_summary(profile, location)
+    summary = profile_summary(profile, None)
     await message.reply_text(
         _menu_text(summary),
         parse_mode=ParseMode.HTML,
@@ -321,10 +306,7 @@ async def directory_command(_, message: Message):
     if not await _verify_group_member(message):
         return
     profile = await observe_directory_user(message.from_user)
-    summary = profile_summary(
-        profile,
-        await get_directory_location(message.from_user.id),
-    )
+    summary = profile_summary(profile, None)
     member = await app.get_chat_member(message.chat.id, message.from_user.id)
     response = await message.reply_text(
         _menu_text(summary),
@@ -345,14 +327,14 @@ async def directory_command(_, message: Message):
 async def nearby_command(_, message: Message):
     if message.chat.type.value != "private" and not await _verify_group_member(message):
         return
-    await _show_radius_picker(message)
+    await _open_service_directory(message)
 
 
 @app.on_message(filters.command("teacher_rank"))
 async def teacher_rank_command(_, message: Message):
     if message.chat.type.value != "private" and not await _verify_group_member(message):
         return
-    await _show_teacher_results(message, radius_km=None, page=1)
+    await _open_service_directory(message)
 
 
 @app.on_message(filters.command("teacher_apply"))
@@ -457,35 +439,6 @@ async def teacher_unlist_command(_, message: Message):
     await _self_state_command(message, "unlist")
 
 
-@app.on_message(filters.private & filters.location, group=12)
-async def directory_location_message(_, message: Message):
-    if not message.from_user or not message.location:
-        return
-    try:
-        await update_user_location(
-            user=message.from_user,
-            longitude=message.location.longitude,
-            latitude=message.location.latitude,
-            source="telegram_location",
-        )
-    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 _refresh_keyboard(message, await get_directory_profile(message.from_user.id))
-
-
-async def _start_manual_location(message: Message) -> None:
-    if not message.from_user:
-        return
-    _location_flows[message.from_user.id] = datetime.now(UTC) + timedelta(minutes=10)
-    await message.reply_text(
-        "请直接发送 Telegram 位置并在地图上选择地点;如果 Telegram 无法定位,"
-        "也可以输入“纬度,经度”,例如:39.9042,116.4074。\n"
-        "发送 /cancel 可取消。"
-    )
-
-
 async def _refresh_keyboard(
     message: Message,
     profile: dict[str, Any] | None,
@@ -498,7 +451,7 @@ async def _refresh_keyboard(
     )
     summary = profile_summary(
         profile or {"user_id": message.from_user.id},
-        await get_directory_location(message.from_user.id),
+        None,
     )
     await message.reply_text(
         "菜单已更新。",
@@ -666,7 +619,7 @@ async def _show_status(target: Message | CallbackQuery) -> None:
     profile = await observe_directory_user(target.from_user)
     summary = profile_summary(
         profile,
-        await get_directory_location(target.from_user.id),
+        None,
     )
     if summary["application_status"] != APPLICATION_APPROVED:
         status_text = {
@@ -681,7 +634,7 @@ async def _show_status(target: Message | CallbackQuery) -> None:
         return
     lines = [
         "<b>我的技师状态</b>",
-        f"位置:{'已更新' if summary['has_location'] else '尚未更新'}",
+        "服务区域:请在 /technician_profile 快速页面填写大概区域",
         f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
         f"上下线:{_presence_label(summary['online'])}",
         (
@@ -704,6 +657,31 @@ async def _show_status(target: Message | CallbackQuery) -> None:
     schedule_group_cleanup(message, response)
 
 
+async def _open_service_directory(target: Message | CallbackQuery) -> None:
+    message = target.message if isinstance(target, CallbackQuery) else target
+    if message.chat.type.value != "private":
+        response = await message.reply_text(
+            "请在 Bot 私聊中浏览技师、咨询和评价。",
+            reply_markup=InlineKeyboardMarkup(
+                [
+                    [
+                        InlineKeyboardButton(
+                            BUTTON_BROWSE,
+                            url=f"https://t.me/{BOT_USERNAME}?start=services",
+                        )
+                    ]
+                ]
+            ),
+        )
+        schedule_group_cleanup(message, response)
+        if isinstance(target, CallbackQuery):
+            await target.answer()
+        return
+    from wbb.modules.technician_services import _ask_search_location
+
+    await _ask_search_location(target)
+
+
 async def _show_radius_picker(target: Message | CallbackQuery) -> None:
     markup = InlineKeyboardMarkup(
         [
@@ -963,13 +941,16 @@ async def _show_admin_teacher_detail(target: CallbackQuery, user_id: int) -> Non
     profile = await get_directory_profile(user_id)
     if not profile:
         return await target.answer("未找到该成员资料。", show_alert=True)
-    summary = profile_summary(profile, await get_directory_location(user_id))
+    summary = profile_summary(profile, None)
+    service_area = str(
+        (profile.get("service_profile") or {}).get("public_area_text") or "尚未填写"
+    )
     lines = [
         f"<b>{escape(summary['display_name'])}</b>",
         f"用户名:@{escape(summary.get('username') or '未设置')}",
         f"用户 ID:<code>{user_id}</code>",
         f"申请:{escape(APPLICATION_LABELS.get(summary['application_status'], '未申请'))}",
-        f"位置:{'已更新' if summary['has_location'] else '尚未更新'}",
+        f"大概服务区域:{escape(service_area)}",
         f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
         f"状态:{_presence_label(summary['online'])}",
     ]
@@ -1104,17 +1085,12 @@ async def directory_callback(_, query: CallbackQuery):
         if parts[1] == "self":
             return await _self_state_command(query, parts[2])
         if parts[1] == "radius":
-            return await _show_radius_picker(query)
+            return await _open_service_directory(query)
         if parts[1] == "list":
-            radius = None if parts[2] == "all" else int(parts[2])
-            return await _show_teacher_results(
-                query,
-                radius_km=radius,
-                page=int(parts[3]),
-            )
+            return await _open_service_directory(query)
         if parts[1] == "clearask":
             return await query.message.edit_text(
-                "清除位置后会自动下榜并下线,确认继续吗?",
+                "确认清除历史精确坐标吗?公开状态和大概服务区域不会受影响。",
                 reply_markup=InlineKeyboardMarkup(
                     [
                         [
@@ -1129,7 +1105,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("已取消。")
@@ -1198,8 +1174,7 @@ async def cancel_directory_flow(_, message: Message):
     if not message.from_user:
         return
     admin_flow = _admin_flows.pop(message.from_user.id, None)
-    location_flow = _location_flows.pop(message.from_user.id, None)
-    if not admin_flow and not location_flow:
+    if not admin_flow:
         return
     await message.reply_text("已取消当前操作。")
     raise StopPropagation
@@ -1240,40 +1215,9 @@ async def directory_private_text(_, message: Message):
                 ]
             ),
         )
-    location_expires_at = _location_flows.get(message.from_user.id)
-    if location_expires_at:
-        if location_expires_at < datetime.now(UTC):
-            _location_flows.pop(message.from_user.id, None)
-            return await message.reply_text("位置输入已超时,请重新选择手动位置。")
-        raw = str(message.text or "").strip().replace(",", ",")
-        values = [value.strip() for value in raw.split(",")]
-        if len(values) != 2:
-            return await message.reply_text(
-                "格式不正确,请输入“纬度,经度”,例如:39.9042,116.4074。"
-            )
-        try:
-            latitude, longitude = (float(value) for value in values)
-            await update_user_location(
-                user=message.from_user,
-                longitude=longitude,
-                latitude=latitude,
-                source="manual_coordinates",
-            )
-        except (DirectoryServiceError, ValueError) as exc:
-            return await message.reply_text(f"位置保存失败:{exc}")
-        _location_flows.pop(message.from_user.id, None)
-        await message.reply_text("手动位置已更新,可立即查找附近技师。")
-        return await _refresh_keyboard(
-            message,
-            await get_directory_profile(message.from_user.id),
-        )
     text = str(message.text or "").strip()
-    if text == BUTTON_MANUAL_LOCATION:
-        return await _start_manual_location(message)
-    if text == BUTTON_NEARBY:
-        return await _show_radius_picker(message)
-    if text == BUTTON_RANK:
-        return await _show_teacher_results(message, radius_km=None, page=1)
+    if text == BUTTON_BROWSE:
+        return await _open_service_directory(message)
     if text == BUTTON_APPLY:
         return await _apply_teacher(message)
     if text == BUTTON_STATUS:
@@ -1282,7 +1226,7 @@ async def directory_private_text(_, message: Message):
         return await _show_status(message)
     if text == BUTTON_CLEAR_LOCATION:
         return await message.reply_text(
-            "清除位置后会自动下榜并下线,确认继续吗?",
+            "确认清除历史精确坐标吗?公开状态和大概服务区域不会受影响。",
             reply_markup=InlineKeyboardMarkup(
                 [
                     [

+ 210 - 138
wbb/modules/technician_services.py

@@ -23,6 +23,7 @@ from pyrogram.types import (
 
 import wbb
 from wbb import BOT_USERNAME, app, log
+from wbb.services.technician_reviews import ensure_technician_review_topic
 from wbb.utils.dbservice import (
     ServiceDataError,
     begin_review_draft,
@@ -36,6 +37,7 @@ from wbb.utils.dbservice import (
     get_active_review_template,
     get_order_for_actor,
     get_technician_review_summary,
+    get_technician_review_topic,
     get_technician_service_profile,
     instantiate_package_template,
     issue_review_qr,
@@ -60,14 +62,10 @@ from wbb.utils.dbservice import (
 )
 
 __MODULE__ = "技师服务"
-__HELP__ = """/find_technician - 查找附近技师并发起服务请求。
-/my_services - 查看我的服务单。
+__HELP__ = """/find_technician - 浏览技师服务并直接咨询。
 /technician_profile - 管理技师服务资料和套餐。
 /review_teacher - 主动评价技师。
 /technician_top - 查看审核评价排行。
-/review_qr - 为服务中的订单生成完成评价二维码。
-/block_customer - 技师拉黑曾发起请求的顾客。
-/report_service - 举报服务单。
 旧 /teacher_top、/teacher_profile 命令继续兼容。"""
 
 ORDER_LABELS = {
@@ -263,10 +261,10 @@ async def _require_terms(target: Message | CallbackQuery) -> bool:
             return False
     await _send_message(
         target,
-        "<b>使用技师服务前请确认</b>\n\n"
-        "平台只记录服务请求、报价、状态与评价,不处理支付和退款。"
-        "时间由双方私聊协商。精确地址仅在报价确认后向履约所需一方开放,"
-        "订单关闭 7 天后自动脱敏。\n\n点击即表示你接受服务规则和隐私说明。",
+        "<b>使用技师咨询与评价前请确认</b>\n\n"
+        "平台提供技师信息展示、咨询入口和公开评价,不处理预约、支付或退款。"
+        "服务细节和具体位置由双方在 Telegram 私聊中沟通。"
+        "\n\n点击即表示你接受服务规则和隐私说明。",
         parse_mode=ParseMode.HTML,
         reply_markup=InlineKeyboardMarkup(
             [[InlineKeyboardButton("接受并继续", callback_data="svc:terms")]]
@@ -282,15 +280,12 @@ async def send_service_home(target: Message | CallbackQuery) -> None:
         return
     await _send_message(
         target,
-        "<b>技师服务</b>\n选择一个操作。服务请求是主要撮合入口,私聊仅用于确认报价后的时间沟通。",
+        "<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:find")],
+                [InlineKeyboardButton("主动评价", callback_data="svc:review_start")],
                 [InlineKeyboardButton("审核评价排行", callback_data="svc:top:all:1")],
             ]
         ),
@@ -300,19 +295,19 @@ async def send_service_home(target: Message | CallbackQuery) -> None:
 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(
+    await _show_nearby(
         target,
-        "请发送当前位置用于计算距离。该位置只保存在当前交互内存中,不会作为精确地址入库。",
-        reply_markup=_location_keyboard("发送当前位置"),
+        longitude=None,
+        latitude=None,
+        page=1,
     )
 
 
 async def _show_nearby(
     target: Message | CallbackQuery,
     *,
-    longitude: float,
-    latitude: float,
+    longitude: float | None,
+    latitude: float | None,
     page: int = 1,
 ) -> None:
     if page == 1:
@@ -324,15 +319,13 @@ async def _show_nearby(
         page_size=6,
     )
     if not items:
-        return await _send_message(target, "附近暂时没有可接单技师。")
+        return await _send_message(target, "当前暂时没有公开接单的技师。")
     rows = []
-    lines = [f"<b>附近技师</b>(共 {total} 位)"]
+    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"\n<b>{escape(item.get('display_name') or str(item['user_id']))}</b>\n"
+            f"{escape(item.get('public_area_text') or '服务区域由技师填写')}\n"
             f"{escape(item.get('headline') or '')}"
         )
         rows.append(
@@ -347,9 +340,6 @@ async def _show_nearby(
         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),
@@ -389,59 +379,63 @@ async def _show_technician(target: Message | CallbackQuery, technician_id: int)
             else "审核评价:暂无"
         ),
     )
-    rows = []
+    rows = [
+        [
+            InlineKeyboardButton(
+                "直接咨询技师",
+                url=f"https://t.me/{profile['username']}",
+            ),
+            InlineKeyboardButton(
+                "评价该技师",
+                callback_data=f"svc:review:{technician_id}",
+            ),
+        ]
+    ]
     for package in service_profile.get("packages", []):
-        modes = "/".join(MODE_LABELS.get(mode, mode) for mode in package["service_modes"])
+        extra_fee = str(
+            (package.get("travel_fee") or {}).get("description") or ""
+        ).strip()
+        if not extra_fee:
+            extra_fee = _travel_fee_text(package)
         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))}",
+            f"\n<b>{escape(package['name'])}</b>\n"
+            f"服务内容:{escape(_short(package['description'], 180))}\n"
+            f"服务价格:{escape(_package_price_text(package))}\n"
+            f"服务时长:约 {package.get('duration_minutes') or '未设'} 分钟\n"
+            f"额外费用:{escape(_short(extra_fee, 120))}",
         ]
-        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))
+    topic = await get_technician_review_topic(technician_id)
+    if not topic:
+        try:
+            topic = await ensure_technician_review_topic(
+                str(getattr(app, "bot_token", "") or ""),
+                technician_id,
+            )
+        except Exception:
+            topic = None
+    if topic and topic.get("topic_url"):
         rows.append(
             [
                 InlineKeyboardButton(
-                    f"发起「{package['name'][:12]}」请求",
-                    callback_data=f"svc:req:{technician_id}:{package['package_id']}",
+                    "查看全部公开评价",
+                    url=str(topic["topic_url"]),
                 )
             ]
         )
-    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 "未填写文字"
+            choice_values = []
+            for value in (review.get("choice_answers") or {}).values():
+                choice_values.extend(value if isinstance(value, list) else [value])
+            comment = ";".join(
+                [
+                    *(str(value) for value in choice_values),
+                    *(review.get("text_answers", {}).values()),
+                ]
+            ) or "未填写补充内容"
             lines.append(
                 f"{escape(review['source_label'])} · {review['score']:.2f} 分 · "
                 f"{escape(review.get('customer_name') or '匿名顾客')}\n"
@@ -786,50 +780,61 @@ async def _begin_review(
     )
     _flow(
         target.from_user.id,
-        "review_rating",
+        "review_question",
         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)]]
+        question_index=int(
+            draft.get("question_index")
+            or draft.get("rating_index")
+            or 0
         ),
+        answers=dict(draft.get("answers") or {}),
     )
+    await _ask_review_question(target)
+
+
+def _review_options_markup(
+    question: dict[str, Any],
+    selected: list[str] | None = None,
+) -> InlineKeyboardMarkup:
+    selected_values = set(selected or [])
+    action = "single" if question["type"] == "single_choice" else "multi"
+    buttons = []
+    for index, option in enumerate(question["options"]):
+        prefix = "✓ " if str(option["value"]) in selected_values else ""
+        buttons.append(
+            InlineKeyboardButton(
+                f"{prefix}{option['label']}",
+                callback_data=f"svc:{action}:{index}",
+            )
+        )
+    rows = [buttons[index : index + 2] for index in range(0, len(buttons), 2)]
+    if question["type"] == "multiple_choice":
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    "完成选择",
+                    callback_data="svc:multi_done",
+                )
+            ]
+        )
+    return InlineKeyboardMarkup(rows)
 
 
-async def _ask_review_text(target: Message | CallbackQuery) -> None:
+async def _ask_review_question(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):
+    questions = flow["template"]["questions"]
+    index = flow["question_index"]
+    if index >= len(questions):
         flow["stage"] = "review_anonymous"
         return await _send_message(
             target,
-            "请选择评价展示方式。默认匿名,审核通过后才会公开并计入排行。",
+            "评价已填写完成。请选择公开显示方式,提交后由管理员审核。",
             reply_markup=InlineKeyboardMarkup(
                 [
                     [InlineKeyboardButton("匿名发布(默认)", callback_data="svc:anonymous:1")],
@@ -837,13 +842,28 @@ async def _ask_review_text(target: Message | CallbackQuery) -> None:
                 ]
             ),
         )
-    question = texts[index]
-    flow["stage"] = "review_text"
+    question = questions[index]
+    if question["type"] == "text":
+        flow["stage"] = "review_text"
+        return await _send_message(
+            target,
+            f"评价 {index + 1}/{len(questions)}:<b>{escape(question['label'])}</b>\n"
+            f"{escape(question.get('description') or '')}\n"
+            f"最多 {question['max_length']} 字。"
+            + ("" if question.get("required") else " 可发送“跳过”。"),
+            parse_mode=ParseMode.HTML,
+        )
+    flow["stage"] = "review_question"
+    selected = flow["answers"].get(question["question_id"])
     await _send_message(
         target,
-        f"文字评价 {index + 1}/{len(texts)}:{question['label']}\n"
-        f"最多 {question['max_length']} 字。"
-        + ("" if question.get("required") else " 可发送“跳过”。"),
+        f"评价 {index + 1}/{len(questions)}:<b>{escape(question['label'])}</b>\n"
+        f"{escape(question.get('description') or '')}",
+        parse_mode=ParseMode.HTML,
+        reply_markup=_review_options_markup(
+            question,
+            selected if isinstance(selected, list) else None,
+        ),
     )
 
 
@@ -864,7 +884,7 @@ async def _submit_pending_review(query: CallbackQuery, anonymous: bool) -> None:
     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]} 已提交审核。审核通过后才会公开并计入审核评价排行。"
+        f"评价 {review['review_id'][:8]} 已提交审核。审核通过后会立即发布到该技师的公开评价话题。"
     )
 
 
@@ -873,20 +893,10 @@ async def _choose_review_package(target: Message | CallbackQuery, technician_id:
     packages = (profile.get("service_profile") or {}).get("packages", [])
     if not packages:
         raise ServiceDataError("package_not_found", "该技师暂无可评价套餐。")
-    await _send_message(
+    await _begin_review(
         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
-            ]
-        ),
+        technician_id=technician_id,
+        source="student_initiated",
     )
 
 
@@ -898,7 +908,7 @@ async def _show_top(target: Message | CallbackQuery, category: str, page: int) -
     )
     lines = [
         "<b>审核评价排行</b>",
-        "主动评价与完成服务单评价审核通过后同等计分;至少 3 条批准评价入榜。",
+        "每条管理员审核通过的评价都会公开并参与排行。",
         "公式:v/(v+5)×技师平均分 + 5/(v+5)×分类平均分。",
     ]
     for index, item in enumerate(items, start=(page - 1) * 10 + 1):
@@ -908,7 +918,7 @@ async def _show_top(target: Message | CallbackQuery, category: str, page: int) -
             f"排行分 {item['rank_score']:.2f} · 平均 {item['average_score']:.2f} · {item['review_count']} 条"
         )
     if not items:
-        lines.append("\n暂时没有满足 3 条审核评价门槛的技师。")
+        lines.append("\n暂时还没有审核通过的评价。")
     rows = []
     if page > 1:
         rows.append([InlineKeyboardButton("上一页", callback_data=f"svc:top:{category}:{page - 1}")])
@@ -1142,6 +1152,11 @@ async def _publish_profile(
     profile = await publish_technician_profile(
         user_id, values, actor_id=user_id
     )
+    with suppress(Exception):
+        await ensure_technician_review_topic(
+            str(getattr(app, "bot_token", "") or ""),
+            user_id,
+        )
     _flows.pop(user_id, None)
     await _send_message(
         target,
@@ -1314,17 +1329,16 @@ async def service_private_text(_, message: Message):
             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"]]
+            question = flow["template"]["questions"][flow["question_index"]]
             if text != "跳过" or question.get("required"):
                 flow["answers"][question["question_id"]] = "" if text == "跳过" else text
-            flow["text_index"] += 1
+            flow["question_index"] += 1
             await save_review_draft_progress(
                 message.from_user.id,
                 answers=flow["answers"],
-                rating_index=flow["rating_index"],
-                text_index=flow["text_index"],
+                question_index=flow["question_index"],
             )
-            await _ask_review_text(message)
+            await _ask_review_question(message)
         elif stage == "template_edit_name":
             flow["package"]["name"] = text
             flow["package"]["customized_from_template"] = True
@@ -1588,13 +1602,10 @@ async def service_callback(_, query: CallbackQuery):
         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"],
+                longitude=None,
+                latitude=None,
                 page=int(parts[2]),
             )
         if action == "tech":
@@ -1689,21 +1700,82 @@ async def service_callback(_, query: CallbackQuery):
                 source="student_initiated",
                 package_id=parts[3],
             )
-        if action == "rate":
+        if action in {"single", "rate"}:
+            flow = _current_flow(query.from_user.id)
+            if not flow or flow["stage"] != "review_question":
+                raise ServiceDataError("review_flow_expired", "评价流程已过期。")
+            question = flow["template"]["questions"][flow["question_index"]]
+            if question["type"] != "single_choice":
+                raise ServiceDataError("invalid_review_answer", "当前问题不是单选题。")
+            if action == "rate":
+                option = next(
+                    (
+                        item
+                        for item in question["options"]
+                        if int(item.get("score") or 0) == int(parts[2])
+                    ),
+                    None,
+                )
+            else:
+                option_index = int(parts[2])
+                option = (
+                    question["options"][option_index]
+                    if 0 <= option_index < len(question["options"])
+                    else None
+                )
+            if not option:
+                raise ServiceDataError("invalid_review_answer", "评价选项无效。")
+            flow["answers"][question["question_id"]] = option["value"]
+            flow["question_index"] += 1
+            await save_review_draft_progress(
+                query.from_user.id,
+                answers=flow["answers"],
+                question_index=flow["question_index"],
+            )
+            await query.answer(f"已选择:{option['label']}")
+            return await _ask_review_question(query)
+        if action == "multi":
+            flow = _current_flow(query.from_user.id)
+            if not flow or flow["stage"] != "review_question":
+                raise ServiceDataError("review_flow_expired", "评价流程已过期。")
+            question = flow["template"]["questions"][flow["question_index"]]
+            if question["type"] != "multiple_choice":
+                raise ServiceDataError("invalid_review_answer", "当前问题不是多选题。")
+            option_index = int(parts[2])
+            if not 0 <= option_index < len(question["options"]):
+                raise ServiceDataError("invalid_review_answer", "评价选项无效。")
+            option_value = str(question["options"][option_index]["value"])
+            selected = list(flow["answers"].get(question["question_id"]) or [])
+            if option_value in selected:
+                selected.remove(option_value)
+            else:
+                selected.append(option_value)
+            flow["answers"][question["question_id"]] = selected
+            await save_review_draft_progress(
+                query.from_user.id,
+                answers=flow["answers"],
+                question_index=flow["question_index"],
+            )
+            await query.message.edit_reply_markup(
+                _review_options_markup(question, selected)
+            )
+            return await query.answer("已更新选择。")
+        if action == "multi_done":
             flow = _current_flow(query.from_user.id)
-            if not flow or flow["stage"] != "review_rating":
+            if not flow or flow["stage"] != "review_question":
                 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
+            question = flow["template"]["questions"][flow["question_index"]]
+            selected = flow["answers"].get(question["question_id"]) or []
+            if question.get("required") and not selected:
+                return await query.answer("请至少选择一项。", show_alert=True)
+            flow["question_index"] += 1
             await save_review_draft_progress(
                 query.from_user.id,
                 answers=flow["answers"],
-                rating_index=flow["rating_index"],
-                text_index=flow["text_index"],
+                question_index=flow["question_index"],
             )
-            await query.answer(f"已选择 {parts[2]} 分。")
-            return await _ask_rating(query)
+            await query.answer("已完成选择。")
+            return await _ask_review_question(query)
         if action == "anonymous":
             await _submit_pending_review(query, parts[2] == "1")
             return await query.answer("评价已提交审核。")

+ 200 - 0
wbb/services/technician_reviews.py

@@ -0,0 +1,200 @@
+from __future__ import annotations
+
+import asyncio
+from html import escape
+from typing import Any
+
+from aiohttp import ClientError, ClientSession, ClientTimeout
+
+from wbb.utils.dbservice import (
+    get_review,
+    get_service_settings,
+    get_technician_review_topic,
+    get_technician_service_profile,
+    mark_review_topic_publish_error,
+    mark_review_topic_published,
+    save_technician_review_topic,
+)
+
+
+class ReviewTopicError(RuntimeError):
+    pass
+
+
+_topic_locks: dict[int, asyncio.Lock] = {}
+
+
+async def _bot_api(
+    bot_token: str,
+    method: str,
+    payload: dict[str, Any],
+) -> dict[str, Any]:
+    if not bot_token:
+        raise ReviewTopicError("当前 Bot 凭据不可用。")
+    url = f"https://api.telegram.org/bot{bot_token}/{method}"
+    try:
+        async with ClientSession(timeout=ClientTimeout(total=20)) as session:
+            async with session.post(url, json=payload) as response:
+                data = await response.json(content_type=None)
+    except (ClientError, TimeoutError, ValueError) as exc:
+        raise ReviewTopicError("Telegram 评价话题请求失败。") from exc
+    if response.status >= 400 or not data.get("ok"):
+        description = str(data.get("description") or "Telegram 返回未知错误")
+        raise ReviewTopicError(description[:240])
+    result = data.get("result")
+    return result if isinstance(result, dict) else {}
+
+
+def _topic_url(forum_username: str, message_thread_id: int) -> str:
+    username = str(forum_username or "").strip().lstrip("@")
+    if not username:
+        raise ReviewTopicError("请先配置公开评价群用户名。")
+    return f"https://t.me/{username}/{int(message_thread_id)}"
+
+
+async def ensure_technician_review_topic(
+    bot_token: str,
+    technician_id: int,
+) -> dict[str, Any] | None:
+    settings = await get_service_settings()
+    forum_chat_id = str(settings.get("review_forum_chat_id") or "").strip()
+    forum_username = str(settings.get("review_forum_username") or "").strip()
+    if not forum_chat_id or not forum_username or not bot_token:
+        return None
+    technician_id = int(technician_id)
+    current = await get_technician_review_topic(technician_id)
+    if current and str(current.get("forum_chat_id")) == forum_chat_id:
+        return current
+    lock = _topic_locks.setdefault(technician_id, asyncio.Lock())
+    async with lock:
+        current = await get_technician_review_topic(technician_id)
+        if current and str(current.get("forum_chat_id")) == forum_chat_id:
+            return current
+        profile = await get_technician_service_profile(technician_id)
+        display_name = str(
+            profile.get("display_name") or f"技师 {technician_id}"
+        ).strip()
+        topic_name = f"{display_name} · 评价"[:128]
+        created = await _bot_api(
+            bot_token,
+            "createForumTopic",
+            {
+                "chat_id": forum_chat_id,
+                "name": topic_name,
+            },
+        )
+        message_thread_id = int(created.get("message_thread_id") or 0)
+        if message_thread_id <= 0:
+            raise ReviewTopicError("Telegram 未返回有效的话题 ID。")
+        topic = await save_technician_review_topic(
+            technician_id=technician_id,
+            forum_chat_id=forum_chat_id,
+            forum_username=forum_username,
+            message_thread_id=message_thread_id,
+            topic_name=topic_name,
+            topic_url=_topic_url(forum_username, message_thread_id),
+        )
+        await _bot_api(
+            bot_token,
+            "sendMessage",
+            {
+                "chat_id": forum_chat_id,
+                "message_thread_id": message_thread_id,
+                "text": (
+                    f"<b>{escape(display_name)}的公开评价</b>\n"
+                    "管理员审核通过的评价会自动发布在这里,任何群成员均可查看。"
+                ),
+                "parse_mode": "HTML",
+                "disable_web_page_preview": True,
+            },
+        )
+        return topic
+
+
+def _review_message(review: dict[str, Any]) -> str:
+    template = review.get("template_snapshot") or {}
+    labels = {
+        str(question.get("question_id")): str(question.get("label") or "")
+        for question in template.get("questions", [])
+    }
+    author = (
+        "匿名顾客"
+        if review.get("anonymous", True)
+        else str(review.get("customer_name") or "顾客")
+    )
+    package_name = str(
+        (review.get("package_snapshot") or {}).get("name") or "其他服务"
+    )
+    lines = [
+        f"<b>新评价 · {float(review.get('score') or 0):.1f} 分</b>",
+        f"技师:{escape(str(review.get('technician_name') or review['technician_id']))}",
+        f"服务:{escape(package_name)}",
+        f"评价人:{escape(author)}",
+    ]
+    choice_answers = review.get("choice_answers") or {}
+    text_answers = review.get("text_answers") or {}
+    for question_id, answer in choice_answers.items():
+        value = "、".join(str(item) for item in answer) if isinstance(answer, list) else str(answer)
+        if value:
+            lines.append(
+                f"{escape(labels.get(str(question_id)) or '选择')}:{escape(value)}"
+            )
+    for question_id, answer in text_answers.items():
+        if answer:
+            lines.append(
+                f"{escape(labels.get(str(question_id)) or '评价内容')}:"
+                f"{escape(str(answer))}"
+            )
+    return "\n".join(lines)
+
+
+async def publish_approved_review(
+    bot_token: str,
+    review: dict[str, Any],
+) -> dict[str, Any]:
+    stored = await get_review(str(review["review_id"]))
+    if stored:
+        review = stored
+    if review.get("topic_message_id"):
+        return {
+            "status": "published",
+            "topic_url": review.get("topic_url"),
+            "message_id": review.get("topic_message_id"),
+        }
+    try:
+        topic = await ensure_technician_review_topic(
+            bot_token,
+            int(review["technician_id"]),
+        )
+        if not topic:
+            return {
+                "status": "unconfigured",
+                "message": "尚未配置公开评价群。",
+            }
+        result = await _bot_api(
+            bot_token,
+            "sendMessage",
+            {
+                "chat_id": topic["forum_chat_id"],
+                "message_thread_id": int(topic["message_thread_id"]),
+                "text": _review_message(review),
+                "parse_mode": "HTML",
+                "disable_web_page_preview": True,
+            },
+        )
+        message_id = int(result.get("message_id") or 0)
+        if message_id <= 0:
+            raise ReviewTopicError("Telegram 未返回有效的评价消息 ID。")
+        await mark_review_topic_published(
+            review["review_id"],
+            topic=topic,
+            message_id=message_id,
+        )
+        return {
+            "status": "published",
+            "topic_url": topic["topic_url"],
+            "message_id": message_id,
+        }
+    except ReviewTopicError as exc:
+        await mark_review_topic_publish_error(review["review_id"], str(exc))
+        return {"status": "failed", "message": str(exc)}

+ 0 - 11
wbb/utils/dbdirectory.py

@@ -369,9 +369,6 @@ async def clear_directory_location(
         {"user_id": int(user_id)},
         {
             "$set": {
-                "listed": False,
-                "presence_status": PRESENCE_OFFLINE,
-                "online_until": None,
                 "location_updated_at": None,
                 "updated_at": utc_now(),
             }
@@ -399,13 +396,10 @@ async def submit_teacher_application(
 ) -> tuple[dict[str, Any], bool]:
     await ensure_directory_indexes()
     profile = await get_directory_profile(int(user_id))
-    location = await get_directory_location(int(user_id))
     if not profile or not profile.get("username"):
         raise DirectoryDataError(
             "username_required", "申请技师前必须先设置 Telegram 用户名。"
         )
-    if not location:
-        raise DirectoryDataError("location_required", "申请技师前必须先更新位置。")
     if profile.get("application_status") == APPLICATION_APPROVED:
         raise DirectoryDataError("already_teacher", "你已经是技师,无需重复申请。")
     if profile.get("application_status") == APPLICATION_REVOKED:
@@ -472,8 +466,6 @@ async def decide_teacher_application(
             raise DirectoryDataError(
                 "username_required", "该成员没有 Telegram 用户名,无法批准。"
             )
-        if not await get_directory_location(int(user_id)):
-            raise DirectoryDataError("location_required", "该成员尚未更新位置。")
     now = utc_now()
     updates: dict[str, Any] = {
         "application_status": action_status[action],
@@ -521,12 +513,9 @@ async def set_teacher_state(
     profile = await get_directory_profile(int(user_id))
     if not profile or profile.get("application_status") != APPLICATION_APPROVED:
         raise DirectoryDataError("teacher_required", "该成员尚未取得技师资格。")
-    location = await get_directory_location(int(user_id))
     if action in {"list", "online"}:
         if not profile.get("username"):
             raise DirectoryDataError("username_required", "请先设置 Telegram 用户名。")
-        if not location:
-            raise DirectoryDataError("location_required", "请先更新位置。")
     if action == "online" and not profile.get("listed"):
         raise DirectoryDataError("listing_required", "请先上榜,再切换为上线状态。")
     if action not in {"list", "unlist", "online", "offline"}:

+ 463 - 129
wbb/utils/dbservice.py

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