浏览代码

feat: add teacher directory and nearby discovery

AI-Co-Authored-By: Codex
chendeben 2 月之前
父节点
当前提交
fda014d800

+ 27 - 1
LOCAL_SETUP.md

@@ -1,7 +1,8 @@
 # Local setup
 
 This fork adds a local Ant Design Pro admin panel, Telegram private management,
-Mongo-backed giveaways, and per-chat points to WilliamButcherBot.
+Mongo-backed giveaways, per-chat points, and a cross-Bot teacher directory to
+WilliamButcherBot.
 
 ## Requirements
 
@@ -120,6 +121,31 @@ Private administration:
 Private menu flows expire after ten minutes. Every action rechecks the
 operator's current Telegram permissions; `SUDOERS` can manage all known groups.
 
+Teacher directory:
+
+```text
+/directory
+/nearby
+/teacher_rank
+/teacher_apply
+/teacher_list
+/teacher_unlist
+/teacher_online
+/teacher_offline
+```
+
+Assign the **Teacher directory manager** role to every Bot that should expose
+these commands. Teacher identity, location, listing, and presence are global
+across all configured Bots. A user must first run `/directory` in a managed
+group so the supervisor can perform a live membership check.
+
+Open **Teacher directory** in the Web panel to review applications and
+configure OpenStreetMap Nominatim. Public Nominatim reverse geocoding remains
+disabled until a contact email or URL is configured. It is globally limited to
+one request per second, cached, and only receives coordinates after the user
+accepts the Telegram privacy prompt. Distance search continues to work when
+Nominatim is disabled or unavailable.
+
 ## Verification
 
 ```console

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

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

+ 5 - 0
admin-web/src/layouts/AdminLayout.tsx

@@ -9,6 +9,7 @@ import {
   LayoutDashboard,
   LogOut,
   MessageSquareMore,
+  MapPinned,
   Settings,
   Star,
   Users,
@@ -31,6 +32,7 @@ const menuItems = [
   { path: '/chats', name: '群组管理', icon: <Users size={18} /> },
   { path: '/giveaways', name: '抽奖管理', icon: <Gift size={18} /> },
   { path: '/points', name: '积分管理', icon: <Star size={18} /> },
+  { path: '/directory', name: '师生与附近', icon: <MapPinned size={18} /> },
   { path: '/audit', name: '操作日志', icon: <ClipboardList size={18} /> },
   { path: '/settings', name: '系统设置', icon: <Settings size={18} /> },
 ];
@@ -97,6 +99,7 @@ export default function AdminLayout() {
     if (item.path === '/chats') return hasAnyPermission(chatPermissions);
     if (item.path === '/points') return hasPermission('points.manage');
     if (item.path === '/giveaways') return hasPermission('giveaways.manage');
+    if (item.path === '/directory') return hasPermission('teacher_directory.manage');
     return true;
   });
 
@@ -107,6 +110,8 @@ export default function AdminLayout() {
         !hasPermission('points.manage')) ||
       (location.pathname.startsWith('/giveaways') &&
         !hasPermission('giveaways.manage')) ||
+      (location.pathname.startsWith('/directory') &&
+        !hasPermission('teacher_directory.manage')) ||
       (location.pathname.startsWith('/chats') &&
         !hasAnyPermission(chatPermissions));
     if (denied) history.replace('/dashboard');

+ 789 - 0
admin-web/src/pages/Directory.tsx

@@ -0,0 +1,789 @@
+import {
+  Alert,
+  Button,
+  Form,
+  Input,
+  InputNumber,
+  Modal,
+  Select,
+  Space,
+  Switch,
+  Table,
+  Tabs,
+  Tag,
+  Typography,
+  message,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import dayjs from 'dayjs';
+import {
+  RefreshCw,
+  Search,
+  Settings2,
+  UserRoundCheck,
+  UserRoundX,
+} from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+import { PageHeader, Surface } from '@/components/Page';
+import { apiRequest, jsonOptions } from '@/services/api';
+import type {
+  DirectoryApplicationStatus,
+  DirectoryEvent,
+  DirectoryLocation,
+  DirectoryProfile,
+  DirectorySettings,
+  Paged,
+} from '@/types';
+
+const emptyPage = <T,>(): Paged<T> => ({
+  items: [],
+  total: 0,
+  page: 1,
+  page_size: 20,
+});
+
+const applicationLabels: Record<DirectoryApplicationStatus, string> = {
+  none: '学生',
+  pending: '待审批',
+  approved: '已批准',
+  rejected: '已拒绝',
+  revoked: '已撤销',
+};
+
+const applicationColors: Record<DirectoryApplicationStatus, string> = {
+  none: 'default',
+  pending: 'processing',
+  approved: 'success',
+  rejected: 'error',
+  revoked: 'warning',
+};
+
+const eventLabels: Record<string, string> = {
+  location_updated: '更新位置',
+  location_cleared: '清除位置',
+  geocode_consent_updated: '区域解析授权',
+  teacher_application_submitted: '提交老师申请',
+  teacher_application_approve: '批准老师',
+  teacher_application_reject: '拒绝申请',
+  teacher_application_revoke: '撤销老师',
+  teacher_list: '上榜',
+  teacher_unlist: '下榜',
+  teacher_online: '上线',
+  teacher_offline: '下线',
+  teacher_auto_offline: '自动下线',
+  teacher_username_removed: '用户名移除',
+};
+
+type ActionTarget = {
+  kind: 'application' | 'profile' | 'location';
+  action: string;
+  user_id: string;
+  display_name?: string | null;
+};
+
+export default function DirectoryPage() {
+  const [applications, setApplications] = useState<Paged<DirectoryProfile>>(emptyPage());
+  const [profiles, setProfiles] = useState<Paged<DirectoryProfile>>(emptyPage());
+  const [locations, setLocations] = useState<Paged<DirectoryLocation>>(emptyPage());
+  const [events, setEvents] = useState<Paged<DirectoryEvent>>(emptyPage());
+  const [settings, setSettings] = useState<DirectorySettings | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [query, setQuery] = useState('');
+  const [applicationStatus, setApplicationStatus] = useState('pending');
+  const [actionTarget, setActionTarget] = useState<ActionTarget | null>(null);
+  const [locationTarget, setLocationTarget] = useState<DirectoryLocation | null>(null);
+  const [actionForm] = Form.useForm<{ reason: string }>();
+  const [locationForm] = Form.useForm<{
+    longitude: number;
+    latitude: number;
+    reason: string;
+  }>();
+  const [settingsForm] = Form.useForm<DirectorySettings>();
+
+  const loadApplications = async (page = 1) => {
+    const params = new URLSearchParams({
+      page: String(page),
+      page_size: '20',
+      status: applicationStatus,
+      query,
+    });
+    setApplications(
+      await apiRequest<Paged<DirectoryProfile>>(`/directory/applications?${params}`),
+    );
+  };
+
+  const loadProfiles = async (page = 1) => {
+    const params = new URLSearchParams({
+      page: String(page),
+      page_size: '20',
+      application_status: 'approved',
+      query,
+    });
+    setProfiles(
+      await apiRequest<Paged<DirectoryProfile>>(`/directory/profiles?${params}`),
+    );
+  };
+
+  const loadLocations = async (page = 1) => {
+    const params = new URLSearchParams({
+      page: String(page),
+      page_size: '20',
+      query,
+    });
+    setLocations(
+      await apiRequest<Paged<DirectoryLocation>>(`/directory/locations?${params}`),
+    );
+  };
+
+  const loadEvents = async (page = 1) => {
+    const params = new URLSearchParams({
+      page: String(page),
+      page_size: '20',
+      query,
+    });
+    setEvents(await apiRequest<Paged<DirectoryEvent>>(`/directory/events?${params}`));
+  };
+
+  const loadSettings = async () => {
+    const result = await apiRequest<DirectorySettings>('/directory/settings');
+    setSettings(result);
+    settingsForm.setFieldsValue(result);
+  };
+
+  const refresh = async () => {
+    setLoading(true);
+    try {
+      await Promise.all([
+        loadApplications(applications.page),
+        loadProfiles(profiles.page),
+        loadLocations(locations.page),
+        loadEvents(events.page),
+        loadSettings(),
+      ]);
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '目录数据加载失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    void refresh();
+  }, [applicationStatus]);
+
+  const approve = (item: DirectoryProfile) => {
+    Modal.confirm({
+      title: `批准 ${item.display_name || item.user_id} 成为老师?`,
+      content: '批准后老师身份、位置和状态将在全部群和全部 Bot 中生效。',
+      okText: '确认批准',
+      onOk: async () => {
+        await apiRequest(
+          `/directory/applications/${item.user_id}/actions`,
+          jsonOptions('POST', { action: 'approve', confirm: true }),
+        );
+        message.success('老师申请已批准');
+        await refresh();
+      },
+    });
+  };
+
+  const openAction = (target: ActionTarget) => {
+    actionForm.resetFields();
+    setActionTarget(target);
+  };
+
+  const submitAction = async ({ reason }: { reason: string }) => {
+    if (!actionTarget) return;
+    if (actionTarget.kind === 'application') {
+      await apiRequest(
+        `/directory/applications/${actionTarget.user_id}/actions`,
+        jsonOptions('POST', {
+          action: actionTarget.action,
+          reason,
+          confirm: true,
+        }),
+      );
+    } else if (actionTarget.kind === 'profile') {
+      await apiRequest(
+        `/directory/profiles/${actionTarget.user_id}/actions`,
+        jsonOptions('POST', {
+          action: actionTarget.action,
+          reason,
+          confirm: true,
+        }),
+      );
+    } else {
+      await apiRequest(
+        `/directory/locations/${actionTarget.user_id}`,
+        jsonOptions('DELETE', { reason, confirm: true }),
+      );
+    }
+    message.success('全平台目录状态已更新');
+    setActionTarget(null);
+    await refresh();
+  };
+
+  const applicationColumns: ColumnsType<DirectoryProfile> = [
+    {
+      title: '成员',
+      minWidth: 210,
+      render: (_, item) => (
+        <Space direction="vertical" size={0}>
+          <Typography.Text strong>{item.display_name || `用户 ${item.user_id}`}</Typography.Text>
+          <Typography.Text type="secondary">
+            {item.username ? `@${item.username}` : '未设置用户名'} · {item.user_id}
+          </Typography.Text>
+        </Space>
+      ),
+    },
+    {
+      title: '状态',
+      dataIndex: 'application_status',
+      width: 110,
+      render: (value: DirectoryApplicationStatus) => (
+        <Tag color={applicationColors[value]}>{applicationLabels[value]}</Tag>
+      ),
+    },
+    {
+      title: '位置',
+      minWidth: 170,
+      render: (_, item) =>
+        item.location ? item.location.region?.label || '区域暂不可用' : '尚未更新',
+    },
+    {
+      title: '申请时间',
+      dataIndex: 'applied_at',
+      width: 165,
+      responsive: ['lg'],
+      render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
+    },
+    {
+      title: '操作',
+      width: 190,
+      fixed: 'right',
+      render: (_, item) => (
+        <Space>
+          {item.application_status !== 'approved' ? (
+            <Button
+              type="link"
+              icon={<UserRoundCheck size={16} />}
+              onClick={() => approve(item)}
+            >
+              批准
+            </Button>
+          ) : null}
+          {item.application_status === 'pending' ? (
+            <Button
+              type="link"
+              danger
+              icon={<UserRoundX size={16} />}
+              onClick={() =>
+                openAction({
+                  kind: 'application',
+                  action: 'reject',
+                  user_id: item.user_id,
+                  display_name: item.display_name,
+                })
+              }
+            >
+              拒绝
+            </Button>
+          ) : null}
+        </Space>
+      ),
+    },
+  ];
+
+  const profileColumns: ColumnsType<DirectoryProfile> = [
+    applicationColumns[0],
+    {
+      title: '榜单',
+      dataIndex: 'listed',
+      width: 90,
+      render: (value) => <Tag color={value ? 'success' : 'default'}>{value ? '已上榜' : '未上榜'}</Tag>,
+    },
+    {
+      title: '上下线',
+      dataIndex: 'online',
+      width: 90,
+      render: (value) => <Tag color={value ? 'processing' : 'default'}>{value ? '在线' : '离线'}</Tag>,
+    },
+    {
+      title: '区域',
+      minWidth: 160,
+      render: (_, item) => item.location?.region?.label || '区域暂不可用',
+    },
+    {
+      title: '自动下线',
+      dataIndex: 'online_until',
+      width: 165,
+      responsive: ['lg'],
+      render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
+    },
+    {
+      title: '操作',
+      width: 300,
+      fixed: 'right',
+      render: (_, item) => (
+        <Space wrap>
+          <Button
+            size="small"
+            onClick={() =>
+              openAction({
+                kind: 'profile',
+                action: item.listed ? 'unlist' : 'list',
+                user_id: item.user_id,
+                display_name: item.display_name,
+              })
+            }
+          >
+            {item.listed ? '强制下榜' : '强制上榜'}
+          </Button>
+          <Button
+            size="small"
+            onClick={() =>
+              openAction({
+                kind: 'profile',
+                action: item.online ? 'offline' : 'online',
+                user_id: item.user_id,
+                display_name: item.display_name,
+              })
+            }
+          >
+            {item.online ? '强制下线' : '强制上线'}
+          </Button>
+          <Button
+            size="small"
+            danger
+            onClick={() =>
+              openAction({
+                kind: 'application',
+                action: 'revoke',
+                user_id: item.user_id,
+                display_name: item.display_name,
+              })
+            }
+          >
+            撤销资格
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  const locationColumns: ColumnsType<DirectoryLocation> = [
+    {
+      title: '成员',
+      minWidth: 210,
+      render: (_, item) => (
+        <Space direction="vertical" size={0}>
+          <Typography.Text strong>{item.display_name || `用户 ${item.user_id}`}</Typography.Text>
+          <Typography.Text type="secondary">{item.username ? `@${item.username}` : item.user_id}</Typography.Text>
+        </Space>
+      ),
+    },
+    {
+      title: '精确坐标',
+      width: 220,
+      render: (_, item) => (
+        <Typography.Text className="numeric">
+          {item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
+        </Typography.Text>
+      ),
+    },
+    {
+      title: '区域',
+      minWidth: 170,
+      render: (_, item) => item.region?.label || '区域暂不可用',
+    },
+    {
+      title: 'Nominatim',
+      width: 130,
+      render: (_, item) => (
+        <Tag color={item.geocode_consent === true ? 'success' : 'default'}>
+          {item.geocode_consent === true
+            ? '已授权'
+            : item.geocode_consent === false
+              ? '已关闭'
+              : '待选择'}
+        </Tag>
+      ),
+    },
+    {
+      title: '更新时间',
+      dataIndex: 'updated_at',
+      width: 165,
+      responsive: ['lg'],
+      render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm'),
+    },
+    {
+      title: '操作',
+      width: 150,
+      fixed: 'right',
+      render: (_, item) => (
+        <Space>
+          <Button
+            type="link"
+            onClick={() => {
+              setLocationTarget(item);
+              locationForm.setFieldsValue({
+                longitude: item.longitude,
+                latitude: item.latitude,
+                reason: '',
+              });
+            }}
+          >
+            修正
+          </Button>
+          <Button
+            type="link"
+            danger
+            onClick={() =>
+              openAction({
+                kind: 'location',
+                action: 'delete',
+                user_id: item.user_id,
+                display_name: item.display_name,
+              })
+            }
+          >
+            清除
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  const eventColumns: ColumnsType<DirectoryEvent> = [
+    {
+      title: '时间',
+      dataIndex: 'created_at',
+      width: 170,
+      render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
+    },
+    {
+      title: '事件',
+      dataIndex: 'event_type',
+      width: 150,
+      render: (value) => <Tag>{eventLabels[value] || value}</Tag>,
+    },
+    { title: '成员 ID', dataIndex: 'user_id', width: 145, className: 'numeric' },
+    { title: '操作者', dataIndex: 'actor_name', width: 150, render: (value) => value || '系统' },
+    { title: '来源', dataIndex: 'source', width: 150, responsive: ['lg'] },
+    { title: '原因', dataIndex: 'reason', minWidth: 220, ellipsis: true },
+  ];
+
+  const searchToolbar = (
+    <div className="page-toolbar">
+      <Input
+        allowClear
+        value={query}
+        onChange={(event) => setQuery(event.target.value)}
+        onPressEnter={() => void refresh()}
+        prefix={<Search size={16} />}
+        placeholder="用户 ID、@username 或昵称"
+        style={{ width: 'min(360px, 100%)' }}
+      />
+      <Button icon={<Search size={16} />} onClick={() => void refresh()}>
+        查询
+      </Button>
+    </div>
+  );
+
+  return (
+    <>
+      <PageHeader
+        title="师生与附近"
+        actions={
+          <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void refresh()}>
+            刷新
+          </Button>
+        }
+      />
+      <Alert
+        showIcon
+        type="warning"
+        message="位置属于敏感信息"
+        description="精确坐标仅供 Web 超级管理员修正;Telegram 榜单只显示距离和省市区。所有状态操作均为全平台生效。"
+        style={{ marginBottom: 16 }}
+      />
+      <Surface>
+        <Tabs
+          items={[
+            {
+              key: 'applications',
+              label: `申请审批 (${applications.total})`,
+              children: (
+                <>
+                  <div className="page-toolbar">
+                    <Select
+                      value={applicationStatus}
+                      onChange={setApplicationStatus}
+                      style={{ width: 150 }}
+                      options={[
+                        { value: 'pending', label: '待审批' },
+                        { value: 'rejected', label: '已拒绝' },
+                        { value: 'revoked', label: '已撤销' },
+                        { value: 'approved', label: '已批准' },
+                      ]}
+                    />
+                    {searchToolbar}
+                  </div>
+                  <Table
+                    rowKey="user_id"
+                    columns={applicationColumns}
+                    dataSource={applications.items}
+                    loading={loading}
+                    pagination={{
+                      current: applications.page,
+                      pageSize: applications.page_size,
+                      total: applications.total,
+                      showSizeChanger: false,
+                    }}
+                    onChange={(pagination) => void loadApplications(pagination.current || 1)}
+                    scroll={{ x: 850 }}
+                  />
+                </>
+              ),
+            },
+            {
+              key: 'teachers',
+              label: `老师目录 (${profiles.total})`,
+              children: (
+                <>
+                  {searchToolbar}
+                  <Table
+                    rowKey="user_id"
+                    columns={profileColumns}
+                    dataSource={profiles.items}
+                    loading={loading}
+                    pagination={{
+                      current: profiles.page,
+                      pageSize: profiles.page_size,
+                      total: profiles.total,
+                      showSizeChanger: false,
+                    }}
+                    onChange={(pagination) => void loadProfiles(pagination.current || 1)}
+                    scroll={{ x: 1150 }}
+                  />
+                </>
+              ),
+            },
+            {
+              key: 'locations',
+              label: `位置管理 (${locations.total})`,
+              children: (
+                <>
+                  {searchToolbar}
+                  <Table
+                    rowKey="user_id"
+                    columns={locationColumns}
+                    dataSource={locations.items}
+                    loading={loading}
+                    pagination={{
+                      current: locations.page,
+                      pageSize: locations.page_size,
+                      total: locations.total,
+                      showSizeChanger: false,
+                    }}
+                    onChange={(pagination) => void loadLocations(pagination.current || 1)}
+                    scroll={{ x: 1050 }}
+                  />
+                </>
+              ),
+            },
+            {
+              key: 'events',
+              label: `状态事件 (${events.total})`,
+              children: (
+                <>
+                  {searchToolbar}
+                  <Table
+                    rowKey="event_id"
+                    columns={eventColumns}
+                    dataSource={events.items}
+                    loading={loading}
+                    pagination={{
+                      current: events.page,
+                      pageSize: events.page_size,
+                      total: events.total,
+                      showSizeChanger: false,
+                    }}
+                    onChange={(pagination) => void loadEvents(pagination.current || 1)}
+                    scroll={{ x: 1000 }}
+                  />
+                </>
+              ),
+            },
+            {
+              key: 'settings',
+              label: '目录设置',
+              forceRender: true,
+              children: (
+                <Form
+                  form={settingsForm}
+                  layout="vertical"
+                  onFinish={(values) => {
+                    Modal.confirm({
+                      title: '确认更新全平台目录设置?',
+                      content: '设置会影响所有具备“师生与附近”职责的机器人。',
+                      okText: '确认保存',
+                      onOk: async () => {
+                        const saved = await apiRequest<DirectorySettings>(
+                          '/directory/settings',
+                          jsonOptions('PUT', { ...values, confirm: true }),
+                        );
+                        setSettings(saved);
+                        settingsForm.setFieldsValue(saved);
+                        message.success('目录设置已保存');
+                      },
+                    });
+                  }}
+                  style={{ maxWidth: 760 }}
+                >
+                  <Alert
+                    showIcon
+                    type={settings?.nominatim_ready ? 'success' : 'info'}
+                    message={
+                      settings?.nominatim_ready
+                        ? 'Nominatim 区域解析已就绪'
+                        : '未填写联系信息时只计算距离,不会发送坐标'
+                    }
+                    style={{ marginBottom: 20 }}
+                  />
+                  <Form.Item
+                    name="nominatim_enabled"
+                    label="启用 OpenStreetMap Nominatim 区域解析"
+                    valuePropName="checked"
+                  >
+                    <Switch />
+                  </Form.Item>
+                  <Form.Item
+                    name="nominatim_contact"
+                    label="应用联系邮箱或 URL"
+                    extra="公共 Nominatim 要求 User-Agent 能识别应用;留空时禁止外发坐标。"
+                  >
+                    <Input placeholder="admin@example.com" />
+                  </Form.Item>
+                  <Form.Item
+                    name="nominatim_endpoint"
+                    label="Nominatim 服务地址"
+                    rules={[{ required: true }]}
+                  >
+                    <Input />
+                  </Form.Item>
+                  <Form.Item
+                    name="nominatim_user_agent"
+                    label="应用 User-Agent"
+                    rules={[{ required: true }]}
+                  >
+                    <Input />
+                  </Form.Item>
+                  <Form.Item name="nearby_radius_km" label="默认附近范围">
+                    <Select
+                      options={(settings?.nearby_radius_options_km || [5, 10, 20, 50]).map(
+                        (value) => ({ value, label: `${value} 公里` }),
+                      )}
+                    />
+                  </Form.Item>
+                  <Space wrap>
+                    <Button type="primary" htmlType="submit" icon={<Settings2 size={16} />}>
+                      保存设置
+                    </Button>
+                    <Button
+                      type="link"
+                      href={settings?.attribution_url || 'https://www.openstreetmap.org/copyright'}
+                      target="_blank"
+                    >
+                      {settings?.attribution || '© OpenStreetMap contributors'}
+                    </Button>
+                  </Space>
+                </Form>
+              ),
+            },
+          ]}
+        />
+      </Surface>
+
+      <Modal
+        title={`确认全平台操作:${actionTarget?.display_name || actionTarget?.user_id || ''}`}
+        open={Boolean(actionTarget)}
+        okText="继续确认"
+        okButtonProps={{ danger: true }}
+        onCancel={() => setActionTarget(null)}
+        onOk={() => actionForm.submit()}
+        destroyOnHidden
+      >
+        <Alert
+          showIcon
+          type="warning"
+          message="此操作将影响全部群和全部 Bot"
+          style={{ marginBottom: 16 }}
+        />
+        <Form form={actionForm} layout="vertical" onFinish={submitAction}>
+          <Form.Item
+            name="reason"
+            label="操作原因"
+            rules={[{ required: true, message: '必须填写操作原因' }]}
+          >
+            <Input.TextArea rows={3} maxLength={500} showCount />
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      <Modal
+        title={`修正位置:${locationTarget?.display_name || locationTarget?.user_id || ''}`}
+        open={Boolean(locationTarget)}
+        okText="继续确认"
+        onCancel={() => setLocationTarget(null)}
+        onOk={() => locationForm.submit()}
+        destroyOnHidden
+      >
+        <Form
+          form={locationForm}
+          layout="vertical"
+          onFinish={(values) => {
+            if (!locationTarget) return;
+            Modal.confirm({
+              title: '确认保存精确坐标?',
+              content: '若该用户已授权区域解析,新坐标会进入 Nominatim 队列。',
+              okText: '确认保存',
+              onOk: async () => {
+                await apiRequest(
+                  `/directory/locations/${locationTarget.user_id}`,
+                  jsonOptions('PUT', { ...values, confirm: true }),
+                );
+                message.success('位置已修正');
+                setLocationTarget(null);
+                await refresh();
+              },
+            });
+          }}
+        >
+          <Form.Item
+            name="longitude"
+            label="经度"
+            rules={[{ required: true }]}
+          >
+            <InputNumber min={-180} max={180} precision={7} style={{ width: '100%' }} />
+          </Form.Item>
+          <Form.Item
+            name="latitude"
+            label="纬度"
+            rules={[{ required: true }]}
+          >
+            <InputNumber min={-90} max={90} precision={7} style={{ width: '100%' }} />
+          </Form.Item>
+          <Form.Item
+            name="reason"
+            label="修正原因"
+            rules={[{ required: true, message: '必须填写修正原因' }]}
+          >
+            <Input.TextArea rows={3} maxLength={500} showCount />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </>
+  );
+}

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

@@ -241,3 +241,79 @@ export interface AuditLog {
   error?: string;
   created_at: string;
 }
+
+export type DirectoryApplicationStatus =
+  | 'none'
+  | 'pending'
+  | 'approved'
+  | 'rejected'
+  | 'revoked';
+
+export interface DirectoryRegion {
+  province?: string;
+  city?: string;
+  district?: string;
+  label: string;
+  provider: 'nominatim';
+}
+
+export interface DirectoryProfile {
+  user_id: string;
+  username?: string | null;
+  first_name?: string | null;
+  last_name?: string | null;
+  display_name?: string | null;
+  application_status: DirectoryApplicationStatus;
+  application_reason?: string;
+  application_source_bot_id?: string;
+  listed: boolean;
+  online: boolean;
+  online_until?: string | null;
+  applied_at?: string | null;
+  decided_at?: string | null;
+  location?: {
+    region?: DirectoryRegion | null;
+    geocode_status?: string;
+    updated_at?: string;
+  } | null;
+}
+
+export interface DirectoryLocation {
+  user_id: string;
+  username?: string | null;
+  display_name?: string | null;
+  application_status: DirectoryApplicationStatus;
+  longitude: number;
+  latitude: number;
+  region?: DirectoryRegion | null;
+  geocode_consent?: boolean | null;
+  geocode_status: string;
+  source: string;
+  updated_at: string;
+}
+
+export interface DirectoryEvent {
+  event_id: string;
+  event_type: string;
+  user_id: string;
+  actor_id?: string | null;
+  actor_name?: string;
+  source: string;
+  reason?: string;
+  bot_id?: string;
+  chat_id?: string | null;
+  created_at: string;
+}
+
+export interface DirectorySettings {
+  nominatim_enabled: boolean;
+  nominatim_endpoint: string;
+  nominatim_contact: string;
+  nominatim_user_agent: string;
+  nominatim_ready: boolean;
+  online_duration_hours: number;
+  nearby_radius_km: number;
+  nearby_radius_options_km: number[];
+  attribution: string;
+  attribution_url: string;
+}

+ 45 - 0
admin-web/tests/e2e/admin.spec.ts

@@ -37,6 +37,7 @@ const permissions = [
   'points.manage',
   'giveaways.manage',
   'karma.manage',
+  'teacher_directory.manage',
   'media.upload',
 ];
 
@@ -178,6 +179,44 @@ test.beforeEach(async ({ page }) => {
       data = { items: [], total: 0, page: 1, page_size: 20 };
     } else if (path.endsWith('/audit-logs')) {
       data = { items: [], total: 0, page: 1, page_size: 20 };
+    } else if (path.endsWith('/directory/applications')) {
+      data = {
+        items: [{
+          user_id: '20001',
+          username: 'teacher_demo',
+          display_name: '示例老师',
+          application_status: 'pending',
+          listed: false,
+          online: false,
+          applied_at: '2026-07-24T08:00:00Z',
+          location: {
+            region: { label: '上海市 黄浦区', provider: 'nominatim' },
+            geocode_status: 'completed',
+          },
+        }],
+        total: 1,
+        page: 1,
+        page_size: 20,
+      };
+    } else if (
+      path.endsWith('/directory/profiles') ||
+      path.endsWith('/directory/locations') ||
+      path.endsWith('/directory/events')
+    ) {
+      data = { items: [], total: 0, page: 1, page_size: 20 };
+    } else if (path.endsWith('/directory/settings')) {
+      data = {
+        nominatim_enabled: true,
+        nominatim_endpoint: 'https://nominatim.openstreetmap.org/reverse',
+        nominatim_contact: '',
+        nominatim_user_agent: 'TelegramBotAdmin/1.0',
+        nominatim_ready: false,
+        online_duration_hours: 24,
+        nearby_radius_km: 20,
+        nearby_radius_options_km: [5, 10, 20, 50],
+        attribution: '© OpenStreetMap contributors',
+        attribution_url: 'https://www.openstreetmap.org/copyright',
+      };
     } else if (path.endsWith('/settings')) {
       data = {
         web_address: 'http://127.0.0.1:8088/admin',
@@ -226,6 +265,7 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
 
   for (const [path, heading] of [
     ['/admin/points', '积分管理'],
+    ['/admin/directory', '师生与附近'],
     ['/admin/audit', '操作日志'],
     ['/admin/settings', '系统设置'],
   ] as const) {
@@ -235,6 +275,11 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
       await expect(page.getByText('全部职责').first()).toBeVisible();
       await page.screenshot({ path: testInfo.outputPath('settings.png'), fullPage: true });
     }
+    if (path === '/admin/directory') {
+      await expect(page.getByText('示例老师')).toBeVisible();
+      await expect(page.getByText('位置属于敏感信息')).toBeVisible();
+      await page.screenshot({ path: testInfo.outputPath('directory.png'), fullPage: true });
+    }
     expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
   }
 });

+ 79 - 0
admin-web/tests/unit/Directory.test.tsx

@@ -0,0 +1,79 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, expect, test, vi } from 'vitest';
+
+import DirectoryPage from '@/pages/Directory';
+import { apiRequest } from '@/services/api';
+
+vi.mock('@/services/api', () => ({
+  apiRequest: vi.fn(),
+  jsonOptions: vi.fn((method, body) => ({ method, body: JSON.stringify(body) })),
+}));
+
+beforeEach(() => {
+  vi.mocked(apiRequest).mockImplementation(async (path) => {
+    if (path.startsWith('/directory/applications')) {
+      return {
+        items: [
+          {
+            user_id: '100',
+            username: 'teacher100',
+            display_name: '王老师',
+            application_status: 'pending',
+            listed: false,
+            online: false,
+            applied_at: '2026-07-24T08:00:00Z',
+            location: {
+              region: { label: '上海市 黄浦区', provider: 'nominatim' },
+              geocode_status: 'completed',
+            },
+          },
+        ],
+        total: 1,
+        page: 1,
+        page_size: 20,
+      };
+    }
+    if (path.startsWith('/directory/profiles')) {
+      return { items: [], total: 0, page: 1, page_size: 20 };
+    }
+    if (path.startsWith('/directory/locations')) {
+      return { items: [], total: 0, page: 1, page_size: 20 };
+    }
+    if (path.startsWith('/directory/events')) {
+      return { items: [], total: 0, page: 1, page_size: 20 };
+    }
+    if (path === '/directory/settings') {
+      return {
+        nominatim_enabled: true,
+        nominatim_endpoint: 'https://nominatim.openstreetmap.org/reverse',
+        nominatim_contact: '',
+        nominatim_user_agent: 'TelegramBotAdmin/1.0',
+        nominatim_ready: false,
+        online_duration_hours: 24,
+        nearby_radius_km: 20,
+        nearby_radius_options_km: [5, 10, 20, 50],
+        attribution: '© OpenStreetMap contributors',
+        attribution_url: 'https://www.openstreetmap.org/copyright',
+      };
+    }
+    throw new Error(`Unexpected request: ${path}`);
+  });
+});
+
+test('展示老师申请、全平台风险提示和Nominatim设置', async () => {
+  render(<DirectoryPage />);
+
+  expect(screen.getByText('师生与附近')).toBeInTheDocument();
+  expect(screen.getByText('位置属于敏感信息')).toBeInTheDocument();
+  expect(await screen.findByText('王老师')).toBeInTheDocument();
+  expect(screen.getByText('@teacher100 · 100')).toBeInTheDocument();
+  expect(screen.getByText('上海市 黄浦区')).toBeInTheDocument();
+
+  fireEvent.click(screen.getByRole('tab', { name: '目录设置' }));
+  expect(
+    await screen.findByText('未填写联系信息时只计算距离,不会发送坐标'),
+  ).toBeInTheDocument();
+  expect(
+    screen.getByRole('link', { name: '© OpenStreetMap contributors' }),
+  ).toHaveAttribute('href', 'https://www.openstreetmap.org/copyright');
+});

+ 2 - 0
config.env.template

@@ -36,3 +36,5 @@ ADMIN_WEB_COOKIE_SECURE=0
 ADMIN_WEB_UPLOAD_MAX_MB=20
 ADMIN_WEB_STATIC_DIR=admin-web/dist
 BOT_PROFILES_PATH=runtime/bot_profiles.json
+
+# Optional Nominatim contact is configured in the admin panel.

+ 5 - 1
launcher.py

@@ -25,7 +25,11 @@ async def _run_supervisor() -> None:
     from wbb.admin.supervisor import BotSupervisor
     from wbb.web_admin import start_admin_web, stop_admin_web
 
-    supervisor = BotSupervisor(wbb.BOT_PROFILES_PATH, project_root=PROJECT_ROOT)
+    supervisor = BotSupervisor(
+        wbb.BOT_PROFILES_PATH,
+        project_root=PROJECT_ROOT,
+        control_port=wbb.ADMIN_WEB_PORT,
+    )
     wbb.BOT_SUPERVISOR = supervisor
     await supervisor.start_enabled()
     await start_admin_web()

+ 2 - 0
pyproject.toml

@@ -63,7 +63,9 @@ include = [
     "wbb/modules/giveaway.py",
     "wbb/modules/karma.py",
     "wbb/modules/points.py",
+    "wbb/modules/teacher_directory.py",
     "wbb/utils/dbadmin.py",
+    "wbb/utils/dbdirectory.py",
     "wbb/utils/dbgiveaway.py",
     "wbb/utils/dbpoints.py",
     "wbb/web_admin.py",

+ 3 - 0
sample_config.py

@@ -60,6 +60,9 @@ ADMIN_BOOTSTRAP_MODE = os.environ.get("WBB_ADMIN_BOOTSTRAP", "0").lower() in ["t
 SUPERVISOR_MODE = os.environ.get("WBB_SUPERVISOR_MODE", "0").lower() in ["true", "1"]
 BOT_PROFILE_ID = os.environ.get("WBB_BOT_PROFILE_ID", "primary")
 BOT_PROFILES_PATH = os.environ.get("BOT_PROFILES_PATH", "runtime/bot_profiles.json")
+BOT_WORKER_MODE = os.environ.get("WBB_BOT_WORKER", "0").lower() in ["true", "1"]
+SUPERVISOR_INTERNAL_URL = os.environ.get("WBB_SUPERVISOR_INTERNAL_URL", "")
+INTERNAL_TOKEN = os.environ.get("WBB_INTERNAL_TOKEN", "")
 _BOT_PERMISSIONS_RAW = os.environ.get("WBB_BOT_PERMISSIONS")
 BOT_PERMISSIONS = (
     {"*"}

+ 3 - 0
tests/conftest.py

@@ -47,6 +47,9 @@ class FakeTelegramApp:
     def on_callback_query(self, *_args, **_kwargs):
         return lambda function: function
 
+    def on_chat_member_updated(self, *_args, **_kwargs):
+        return lambda function: function
+
 
 @pytest.fixture
 def app_modules(tmp_path):

+ 1 - 0
tests/test_bot_config.py

@@ -34,6 +34,7 @@ def test_multiple_bot_profiles_are_atomic_and_redacted(app_modules, tmp_path):
     assert status["bots"][0]["sudo_users_id"] == ["100", "200"]
     assert status["bots"][0]["role_ids"] == ["full_access"]
     assert status["bots"][0]["permissions"]
+    assert "teacher_directory.manage" in status["bots"][0]["permissions"]
     serialized = json.dumps(status)
     assert first_token not in serialized
     assert second_token not in serialized

+ 358 - 0
tests/test_directory.py

@@ -0,0 +1,358 @@
+from __future__ import annotations
+
+from datetime import timedelta
+from types import SimpleNamespace
+
+import pytest
+from aiohttp import CookieJar
+from aiohttp.test_utils import TestClient, TestServer
+from pyrogram.enums import ChatMemberStatus
+
+
+async def _approved_teacher(directory_db, *, user_id: int = 200):
+    await directory_db.upsert_directory_identity(
+        user_id=user_id,
+        username=f"teacher{user_id}",
+        first_name="王",
+        last_name="老师",
+    )
+    await directory_db.save_directory_location(
+        user_id=user_id,
+        longitude=116.4074,
+        latitude=39.9042,
+        source="test",
+        actor_id=user_id,
+        actor_name="王老师",
+        geocode_consent=False,
+    )
+    await directory_db.submit_teacher_application(
+        user_id=user_id,
+        source="test",
+        bot_id="primary",
+    )
+    await directory_db.decide_teacher_application(
+        user_id=user_id,
+        action="approve",
+        actor_id="admin",
+        actor_name="admin",
+        source="test",
+    )
+    await directory_db.set_teacher_state(
+        user_id=user_id,
+        action="list",
+        actor_id=user_id,
+        actor_name="王老师",
+        source="test",
+    )
+    return await directory_db.get_directory_profile(user_id)
+
+
+async def test_teacher_application_distance_listing_and_presence(app_modules):
+    directory_db = app_modules.load("wbb.utils.dbdirectory")
+    directory_service = app_modules.load("wbb.services.directory")
+    await directory_db.ensure_directory_indexes()
+
+    await _approved_teacher(directory_db)
+    profile, applied = await directory_db.set_teacher_state(
+        user_id=200,
+        action="online",
+        actor_id=200,
+        actor_name="王老师",
+        source="test",
+        idempotency_key="presence:one",
+    )
+    assert applied is True
+    assert directory_db.teacher_is_online(profile) is True
+
+    duplicate, applied = await directory_db.set_teacher_state(
+        user_id=200,
+        action="online",
+        actor_id=200,
+        actor_name="王老师",
+        source="test",
+        idempotency_key="presence:one",
+    )
+    assert applied is False
+    assert directory_db.teacher_is_online(duplicate) is True
+
+    items, total = await directory_db.list_directory_teachers(
+        longitude=116.4174,
+        latitude=39.9042,
+        page=1,
+        page_size=10,
+    )
+    assert total == 1
+    public = directory_service.public_teacher(items[0])
+    assert public["display_name"] == "王 老师"
+    assert 800 < public["distance_meters"] < 1000
+    assert "longitude" not in public
+    assert "latitude" not in public
+
+    await app_modules.wbb.control_db.directory_profiles.update_one(
+        {"user_id": 200},
+        {
+            "$set": {
+                "online_until": directory_db.utc_now() - timedelta(seconds=1),
+            }
+        },
+    )
+    assert await directory_db.expire_teacher_presence() == 1
+    expired = await directory_db.get_directory_profile(200)
+    assert directory_db.teacher_is_online(expired) is False
+    assert expired["listed"] is True
+
+
+async def test_location_consent_geocode_queue_and_cache(app_modules):
+    directory_db = app_modules.load("wbb.utils.dbdirectory")
+    geocoding = app_modules.load("wbb.services.directory_geocoding")
+    await directory_db.upsert_directory_identity(
+        user_id=300,
+        username="student300",
+        first_name="李",
+        last_name="同学",
+    )
+    location = await directory_db.save_directory_location(
+        user_id=300,
+        longitude=121.4737,
+        latitude=31.2304,
+        source="test",
+        actor_id=300,
+        actor_name="李同学",
+    )
+    assert location["geocode_consent"] is None
+    assert await directory_db.claim_geocode_job() is None
+
+    await directory_db.set_geocode_consent(
+        user_id=300,
+        consent=True,
+        source="test",
+        actor_id=300,
+        actor_name="李同学",
+    )
+    job = await directory_db.claim_geocode_job()
+    assert job["longitude"] == pytest.approx(121.4737)
+    region = geocoding.parse_nominatim_region(
+        {
+            "address": {
+                "state": "上海市",
+                "city": "上海市",
+                "city_district": "黄浦区",
+                "road": "测试道路",
+            }
+        }
+    )
+    assert region["label"] == "上海市 黄浦区"
+    assert "road" not in region
+    await directory_db.complete_geocode_job(job=job, region=region)
+
+    completed = await directory_db.get_directory_location(300)
+    assert completed["geocode_status"] == "completed"
+    assert completed["region"]["district"] == "黄浦区"
+    await directory_db.enqueue_geocode_job(300)
+    assert await directory_db.claim_geocode_job() is None
+
+    await directory_db.set_geocode_consent(
+        user_id=300,
+        consent=False,
+        source="test",
+        actor_id=300,
+        actor_name="李同学",
+    )
+    disabled = await directory_db.get_directory_location(300)
+    assert disabled["region"] is None
+    assert disabled["geocode_status"] == "disabled"
+
+
+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")
+    user = SimpleNamespace(
+        id=400,
+        username="member400",
+        first_name="测试",
+        last_name="成员",
+        is_bot=False,
+    )
+    app_modules.app.members[(-100123, 400)] = SimpleNamespace(
+        status=ChatMemberStatus.ADMINISTRATOR,
+        user=user,
+    )
+    await directory_db.upsert_directory_membership(
+        bot_id="primary",
+        chat_id=-100123,
+        user_id=400,
+        status="member",
+        active=True,
+    )
+
+    member = await directory_service.verify_platform_membership(user_id=400)
+    assert member["allowed"] is True
+    assert member["chat_id"] == -100123
+    admin = await directory_service.verify_platform_membership(
+        user_id=400,
+        require_admin=True,
+    )
+    assert admin["allowed"] is True
+
+    app_modules.app.members[(-100123, 400)] = SimpleNamespace(
+        status=ChatMemberStatus.LEFT,
+        user=user,
+    )
+    denied = await directory_service.verify_platform_membership(user_id=400)
+    assert denied["allowed"] is False
+
+
+async def test_directory_admin_api_requires_role_and_manages_global_data(app_modules):
+    admin_api = app_modules.load("wbb.admin.api")
+    directory_db = app_modules.load("wbb.utils.dbdirectory")
+    app_modules.wbb.BOT_PERMISSIONS = {"teacher_directory.manage"}
+    application = admin_api.build_admin_application()
+    await application["admin_api"].initialize()
+    client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
+    await client.start_server()
+    try:
+        login = await client.post(
+            "/api/admin/v1/auth/login",
+            json={"username": "admin", "password": "qwe0.123456"},
+        )
+        login_data = (await login.json())["data"]
+        changed = await client.put(
+            "/api/admin/v1/auth/password",
+            headers={"X-CSRF-Token": login_data["csrf_token"]},
+            json={
+                "current_password": "qwe0.123456",
+                "new_password": "changed-pass-123",
+            },
+        )
+        csrf = (await changed.json())["data"]["csrf_token"]
+        headers = {"X-CSRF-Token": csrf}
+
+        await directory_db.upsert_directory_identity(
+            user_id=500,
+            username="student500",
+            first_name="周",
+            last_name="同学",
+        )
+        updated = await client.put(
+            "/api/admin/v1/directory/locations/500",
+            headers=headers,
+            json={
+                "longitude": 113.2644,
+                "latitude": 23.1291,
+                "reason": "修正测试位置",
+                "confirm": True,
+            },
+        )
+        assert updated.status == 200
+        payload = (await updated.json())["data"]
+        assert payload["longitude"] == pytest.approx(113.2644)
+
+        listed = await client.get("/api/admin/v1/directory/locations")
+        assert listed.status == 200
+        item = (await listed.json())["data"]["items"][0]
+        assert item["latitude"] == pytest.approx(23.1291)
+        assert "point" not in item
+        assert "coordinate_key" not in item
+
+        unconfirmed = await client.delete(
+            "/api/admin/v1/directory/locations/500",
+            headers=headers,
+            json={"reason": "测试"},
+        )
+        assert unconfirmed.status == 409
+        deleted = await client.delete(
+            "/api/admin/v1/directory/locations/500",
+            headers=headers,
+            json={"reason": "删除测试位置", "confirm": True},
+        )
+        assert deleted.status == 200
+        assert (await deleted.json())["data"]["deleted"] is True
+
+        app_modules.wbb.BOT_PERMISSIONS = set()
+        denied = await client.get("/api/admin/v1/directory/settings")
+        assert denied.status == 403
+        assert (await denied.json())["error"]["details"]["required_permission"] == (
+            "teacher_directory.manage"
+        )
+    finally:
+        await client.close()
+
+
+async def test_role_specific_bottom_menus_are_chinese(app_modules):
+    module = app_modules.load("wbb.modules.teacher_directory")
+    directory_service = app_modules.load("wbb.services.directory")
+    try:
+        student = module._directory_keyboard(
+            {"application_status": "none"},
+            is_admin=False,
+        )
+        student_labels = [
+            button.text for row in student.keyboard for button in row
+        ]
+        assert "更新位置" in student_labels
+        assert "附近老师" in student_labels
+        assert "申请成为老师" in student_labels
+        assert "老师审批" not in student_labels
+
+        teacher_admin = module._directory_keyboard(
+            {
+                "application_status": "approved",
+                "listed": True,
+                "online": True,
+            },
+            is_admin=True,
+        )
+        teacher_labels = [
+            button.text for row in teacher_admin.keyboard for button in row
+        ]
+        assert "下榜" in teacher_labels
+        assert "下线" in teacher_labels
+        assert "老师审批" in teacher_labels
+    finally:
+        await directory_service.stop_presence_sweeper()
+
+
+async def test_internal_membership_rpc_requires_loopback_token(app_modules):
+    admin_api = app_modules.load("wbb.admin.api")
+    app_modules.wbb.INTERNAL_TOKEN = "internal-test-token"
+    application = admin_api.build_admin_application()
+    await application["admin_api"].initialize()
+    client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
+    await client.start_server()
+    user = SimpleNamespace(
+        id=600,
+        username="admin600",
+        first_name="目录",
+        last_name="管理员",
+        is_bot=False,
+    )
+    app_modules.app.members[(-100600, 600)] = SimpleNamespace(
+        status=ChatMemberStatus.ADMINISTRATOR,
+        user=user,
+    )
+    try:
+        denied = await client.post(
+            "/api/internal/v1/directory/verify-local",
+            json={
+                "user_id": "600",
+                "chat_ids": ["-100600"],
+                "require_admin": True,
+            },
+        )
+        assert denied.status == 403
+
+        allowed = await client.post(
+            "/api/internal/v1/directory/verify-local",
+            headers={"Authorization": "Bearer internal-test-token"},
+            json={
+                "user_id": "600",
+                "chat_ids": ["-100600"],
+                "require_admin": True,
+            },
+        )
+        assert allowed.status == 200
+        data = (await allowed.json())["data"]
+        assert data["allowed"] is True
+        assert data["chat_id"] == "-100600"
+    finally:
+        await client.close()

+ 1 - 0
wbb/__init__.py

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

+ 43 - 4
wbb/__main__.py

@@ -50,6 +50,7 @@ from wbb import (
 from wbb.core.keyboard import ikb
 from wbb.modules import ALL_MODULES
 from wbb.services.bot_permissions import (
+    GROUP_MANAGEMENT_PERMISSIONS,
     PRIVATE_MANAGEMENT_PERMISSIONS,
     TELEGRAM_COMMAND_PERMISSIONS,
     has_any_permission,
@@ -99,9 +100,17 @@ BOT_COMMANDS = [
     BotCommand("gcancel", "取消抽奖并退款"),
     BotCommand("greroll", "重新抽取中奖者"),
     BotCommand("rules", "查看本群群规"),
+    BotCommand("directory", "打开师生与附近菜单"),
+    BotCommand("nearby", "查找附近老师"),
+    BotCommand("teacher_rank", "查看老师榜单"),
+    BotCommand("teacher_apply", "申请成为老师"),
+    BotCommand("teacher_online", "切换老师为上线"),
+    BotCommand("teacher_offline", "切换老师为下线"),
+    BotCommand("teacher_list", "老师自助上榜"),
+    BotCommand("teacher_unlist", "老师自助下榜"),
 ]
 BOT_COMMAND_PERMISSIONS = {
-    "manage": "__any__",
+    "manage": "__group_any__",
     "cancel": "__any__",
     "points": "points.manage",
     "checkin": "points.manage",
@@ -115,6 +124,14 @@ BOT_COMMAND_PERMISSIONS = {
     "gcancel": "giveaways.manage",
     "greroll": "giveaways.manage",
     "rules": "chat.rules",
+    "directory": "teacher_directory.manage",
+    "nearby": "teacher_directory.manage",
+    "teacher_rank": "teacher_directory.manage",
+    "teacher_apply": "teacher_directory.manage",
+    "teacher_online": "teacher_directory.manage",
+    "teacher_offline": "teacher_directory.manage",
+    "teacher_list": "teacher_directory.manage",
+    "teacher_unlist": "teacher_directory.manage",
 }
 
 
@@ -122,11 +139,15 @@ def _visible_bot_commands() -> list[BotCommand]:
     visible = []
     for command in BOT_COMMANDS:
         required = BOT_COMMAND_PERMISSIONS.get(command.command)
+        if required == "__group_any__" and not has_any_permission(
+            GROUP_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS
+        ):
+            continue
         if required == "__any__" and not has_any_permission(
             PRIVATE_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS
         ):
             continue
-        if required and required != "__any__" and not has_permission(
+        if required and required not in {"__any__", "__group_any__"} and not has_permission(
             required, BOT_PERMISSIONS
         ):
             continue
@@ -252,10 +273,17 @@ async def start_bot():
 home_actions = [
     InlineKeyboardButton(text="功能帮助", callback_data="bot_commands"),
 ]
-if has_any_permission(PRIVATE_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS):
+if has_any_permission(GROUP_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS):
     home_actions.append(
         InlineKeyboardButton(text="群组管理", callback_data="manage_hint")
     )
+if has_permission("teacher_directory.manage", BOT_PERMISSIONS):
+    home_actions.append(
+        InlineKeyboardButton(
+            text="师生与附近",
+            url=f"https://t.me/{BOT_USERNAME}?start=directory",
+        )
+    )
 
 home_keyboard_pm = InlineKeyboardMarkup(
     [
@@ -289,6 +317,7 @@ assigned_features = [
         ("points.manage", "积分"),
         ("giveaways.manage", "抽奖"),
         ("karma.manage", "声望"),
+        ("teacher_directory.manage", "师生与附近"),
     )
     if has_permission(permission, BOT_PERMISSIONS)
 ]
@@ -301,7 +330,7 @@ home_text_pm = (
     )
     + (
         "群管理员可发送 /manage 打开私聊管理菜单。"
-        if has_any_permission(PRIVATE_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS)
+        if has_any_permission(GROUP_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS)
         else ""
     )
 )
@@ -374,6 +403,12 @@ async def start(_, message):
                     "该群管理员尚未设置群规。",
                 )
             return
+        if name == "directory" and has_permission(
+            "teacher_directory.manage", BOT_PERMISSIONS
+        ):
+            from wbb.modules.teacher_directory import send_directory_menu
+
+            return await send_directory_menu(message)
         if name == "mkdwn_help":
             await message.reply(
                 MARKDOWN,
@@ -412,6 +447,10 @@ async def start(_, message):
             home_text_pm,
             reply_markup=home_keyboard_pm,
         )
+        if has_permission("teacher_directory.manage", BOT_PERMISSIONS):
+            from wbb.modules.teacher_directory import send_directory_menu
+
+            await send_directory_menu(message)
     return
 
 

+ 496 - 1
wbb/admin/api.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import csv
 import io
+import secrets
 import tempfile
 import traceback
 from contextlib import suppress
@@ -55,6 +56,11 @@ from wbb.services.chat_management import (
     update_chat_permissions,
     update_chat_profile,
 )
+from wbb.services.directory import DirectoryServiceError, verify_local_membership
+from wbb.services.directory_geocoding import (
+    start_directory_geocoder,
+    stop_directory_geocoder,
+)
 from wbb.services.giveaways import (
     GiveawayServiceError,
     cancel_and_refund_giveaway,
@@ -78,6 +84,24 @@ from wbb.utils.dbadmin import (
     revoke_admin_session,
     update_admin_password,
 )
+from wbb.utils.dbdirectory import (
+    APPLICATION_APPROVED,
+    DirectoryDataError,
+    clear_directory_location,
+    decide_teacher_application,
+    ensure_directory_indexes,
+    get_directory_location,
+    get_directory_profile,
+    get_directory_settings,
+    list_directory_events,
+    list_directory_locations,
+    list_directory_profiles,
+    list_membership_candidates,
+    list_teacher_applications,
+    save_directory_location,
+    set_directory_settings,
+    set_teacher_state,
+)
 from wbb.utils.dbfunctions import get_rules, set_chat_rules
 from wbb.utils.dbgiveaway import (
     add_giveaway_ban,
@@ -117,6 +141,7 @@ TELEGRAM_ID_KEYS = {
     "target_id",
     "message_id",
     "removed_by",
+    "authorization_chat_id",
 }
 
 
@@ -249,12 +274,16 @@ async def api_error_middleware(request: web.Request, handler):
     except ApiProblem as exc:
         await _record_request_audit(request, success_state=False, error=str(exc))
         return error_response(exc)
-    except (ChatManagementError, GiveawayServiceError) as exc:
+    except (ChatManagementError, GiveawayServiceError, DirectoryServiceError) as exc:
         problem = ApiProblem(
             exc.code, str(exc), status=getattr(exc, "status", 400)
         )
         await _record_request_audit(request, success_state=False, error=str(exc))
         return error_response(problem)
+    except DirectoryDataError as exc:
+        problem = ApiProblem(exc.code, str(exc), status=409)
+        await _record_request_audit(request, success_state=False, error=str(exc))
+        return error_response(problem)
     except (PointsError, InsufficientPoints) as exc:
         problem = ApiProblem("points_error", str(exc), status=409)
         await _record_request_audit(request, success_state=False, error=str(exc))
@@ -354,6 +383,29 @@ def _selected_bot_id(request: web.Request) -> str:
     )
 
 
+def _require_internal_request(request: web.Request) -> None:
+    supervisor = getattr(wbb, "BOT_SUPERVISOR", None)
+    expected = str(
+        getattr(supervisor, "internal_token", "")
+        or getattr(wbb, "INTERNAL_TOKEN", "")
+        or ""
+    )
+    supplied = request.headers.get("Authorization", "")
+    token = supplied.removeprefix("Bearer ").strip()
+    if not expected or not token or not secrets.compare_digest(expected, token):
+        raise ApiProblem(
+            "internal_auth_failed",
+            "内部服务鉴权失败。",
+            status=403,
+        )
+    if request.remote and request.remote not in {"127.0.0.1", "::1"}:
+        raise ApiProblem(
+            "internal_loopback_required",
+            "内部服务只接受本机请求。",
+            status=403,
+        )
+
+
 @web.middleware
 async def bot_role_middleware(request: web.Request, handler):
     required = api_permission(request.method, request.path)
@@ -452,9 +504,22 @@ class AdminApi:
             username=self.username,
             password_hash=hash_password(self.initial_password),
         )
+        await ensure_directory_indexes()
 
     def register(self, application: web.Application) -> None:
         router = application.router
+        router.add_post(
+            "/api/internal/v1/directory/verify-platform",
+            self.internal_directory_verify_platform,
+        )
+        router.add_post(
+            "/api/internal/v1/directory/verify-local",
+            self.internal_directory_verify_local,
+        )
+        router.add_post(
+            "/api/internal/v1/directory/notify-local",
+            self.internal_directory_notify_local,
+        )
         router.add_get(f"{API_PREFIX}/health", self.health)
         router.add_post(f"{API_PREFIX}/auth/login", self.login)
         router.add_get(f"{API_PREFIX}/auth/me", self.me)
@@ -509,6 +574,35 @@ class AdminApi:
         router.add_post(f"{API_PREFIX}/points/adjustments", self.point_adjustment)
         router.add_get(f"{API_PREFIX}/points/export", self.points_export)
 
+        router.add_get(f"{API_PREFIX}/directory/settings", self.directory_settings)
+        router.add_put(
+            f"{API_PREFIX}/directory/settings",
+            self.directory_settings_update,
+        )
+        router.add_get(
+            f"{API_PREFIX}/directory/applications",
+            self.directory_applications,
+        )
+        router.add_post(
+            f"{API_PREFIX}/directory/applications/{{user_id}}/actions",
+            self.directory_application_action,
+        )
+        router.add_get(f"{API_PREFIX}/directory/profiles", self.directory_profiles)
+        router.add_post(
+            f"{API_PREFIX}/directory/profiles/{{user_id}}/actions",
+            self.directory_profile_action,
+        )
+        router.add_get(f"{API_PREFIX}/directory/locations", self.directory_locations)
+        router.add_put(
+            f"{API_PREFIX}/directory/locations/{{user_id}}",
+            self.directory_location_update,
+        )
+        router.add_delete(
+            f"{API_PREFIX}/directory/locations/{{user_id}}",
+            self.directory_location_delete,
+        )
+        router.add_get(f"{API_PREFIX}/directory/events", self.directory_events)
+
         router.add_get(f"{API_PREFIX}/giveaways", self.giveaways)
         router.add_post(f"{API_PREFIX}/giveaways", self.giveaway_create)
         router.add_get(f"{API_PREFIX}/giveaways/{{giveaway_id}}", self.giveaway_detail)
@@ -534,6 +628,395 @@ class AdminApi:
     async def health(self, _: web.Request) -> web.Response:
         return success({"status": "ok"})
 
+    async def internal_directory_verify_platform(
+        self,
+        request: web.Request,
+    ) -> web.Response:
+        _require_internal_request(request)
+        body = await json_body(request)
+        user_id = parse_int(body.get("user_id"), "user_id", minimum=1)
+        require_admin = bool(body.get("require_admin"))
+        candidates = await list_membership_candidates(user_id)
+        if not candidates:
+            return success(
+                {"allowed": False, "reason": "membership_evidence_required"}
+            )
+        supervisor = getattr(wbb, "BOT_SUPERVISOR", None)
+        if supervisor is None:
+            local = [
+                int(item["chat_id"])
+                for item in candidates
+                if str(item.get("bot_id")) == str(getattr(wbb, "BOT_PROFILE_ID", "primary"))
+            ]
+            return success(
+                await verify_local_membership(
+                    user_id=user_id,
+                    chat_ids=local,
+                    require_admin=require_admin,
+                )
+            )
+        candidates_by_bot: dict[str, list[int]] = {}
+        for item in candidates:
+            candidates_by_bot.setdefault(str(item["bot_id"]), []).append(
+                int(item["chat_id"])
+            )
+        for bot_id, chat_ids in candidates_by_bot.items():
+            endpoint = supervisor.endpoint_for(bot_id)
+            if endpoint is None:
+                continue
+            try:
+                async with ClientSession(timeout=ClientTimeout(total=15)) as session:
+                    async with session.post(
+                        f"{endpoint}/api/internal/v1/directory/verify-local",
+                        headers={
+                            "Authorization": f"Bearer {supervisor.internal_token}"
+                        },
+                        json={
+                            "user_id": str(user_id),
+                            "chat_ids": [str(value) for value in chat_ids],
+                            "require_admin": require_admin,
+                        },
+                    ) as response:
+                        if response.status != 200:
+                            continue
+                        result = (await response.json()).get("data") or {}
+                        if result.get("allowed"):
+                            return success(result)
+            except (ClientError, TimeoutError, ValueError):
+                continue
+        return success({"allowed": False})
+
+    async def internal_directory_verify_local(
+        self,
+        request: web.Request,
+    ) -> web.Response:
+        _require_internal_request(request)
+        body = await json_body(request)
+        user_id = parse_int(body.get("user_id"), "user_id", minimum=1)
+        raw_chat_ids = body.get("chat_ids")
+        if not isinstance(raw_chat_ids, list):
+            raise ApiProblem("invalid_parameter", "chat_ids 必须是数组。")
+        chat_ids = [parse_int(value, "chat_id") for value in raw_chat_ids[:200]]
+        return success(
+            await verify_local_membership(
+                user_id=user_id,
+                chat_ids=chat_ids,
+                require_admin=bool(body.get("require_admin")),
+            )
+        )
+
+    async def internal_directory_notify_local(
+        self,
+        request: web.Request,
+    ) -> web.Response:
+        _require_internal_request(request)
+        body = await json_body(request)
+        user_id = parse_int(body.get("user_id"), "user_id", minimum=1)
+        text = str(body.get("text") or "").strip()
+        if not text:
+            raise ApiProblem("invalid_parameter", "通知内容不能为空。")
+        try:
+            await telegram_app.send_message(user_id, text)
+        except Exception as exc:
+            raise ApiProblem(
+                "telegram_notification_failed",
+                "无法向该用户发送 Telegram 通知。",
+                status=409,
+            ) from exc
+        return success({"sent": True})
+
+    async def _notify_directory_user(
+        self,
+        *,
+        profile: dict[str, Any],
+        text: str,
+    ) -> None:
+        bot_id = str(
+            profile.get("application_source_bot_id")
+            or getattr(wbb, "BOT_PROFILE_ID", "primary")
+        )
+        supervisor = getattr(wbb, "BOT_SUPERVISOR", None)
+        if supervisor is None:
+            with suppress(Exception):
+                await telegram_app.send_message(int(profile["user_id"]), text)
+            return
+        endpoint = supervisor.endpoint_for(bot_id)
+        if endpoint is None:
+            return
+        with suppress(Exception):
+            async with ClientSession(timeout=ClientTimeout(total=10)) as session:
+                await session.post(
+                    f"{endpoint}/api/internal/v1/directory/notify-local",
+                    headers={
+                        "Authorization": f"Bearer {supervisor.internal_token}"
+                    },
+                    json={"user_id": str(profile["user_id"]), "text": text},
+                )
+
+    async def directory_settings(self, _: web.Request) -> web.Response:
+        settings = await get_directory_settings()
+        settings["nominatim_ready"] = bool(
+            settings.get("nominatim_enabled")
+            and str(settings.get("nominatim_contact") or "").strip()
+        )
+        settings["attribution"] = "© OpenStreetMap contributors"
+        settings["attribution_url"] = "https://www.openstreetmap.org/copyright"
+        return success(settings)
+
+    async def directory_settings_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        values = {
+            key: body[key]
+            for key in (
+                "nominatim_enabled",
+                "nominatim_endpoint",
+                "nominatim_contact",
+                "nominatim_user_agent",
+                "nearby_radius_km",
+                "nearby_radius_options_km",
+            )
+            if key in body
+        }
+        saved = await set_directory_settings(values)
+        set_audit(
+            request,
+            "directory.settings.update",
+            summary="更新师生目录和 Nominatim 设置",
+        )
+        return success(
+            {
+                **saved,
+                "nominatim_ready": bool(
+                    saved.get("nominatim_enabled")
+                    and str(saved.get("nominatim_contact") or "").strip()
+                ),
+                "attribution": "© OpenStreetMap contributors",
+                "attribution_url": "https://www.openstreetmap.org/copyright",
+            }
+        )
+
+    async def directory_applications(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_teacher_applications(
+            status=str(request.query.get("status") or ""),
+            query=str(request.query.get("query") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {
+                "items": items,
+                "total": total,
+                "page": page,
+                "page_size": page_size,
+            }
+        )
+
+    async def directory_application_action(
+        self,
+        request: web.Request,
+    ) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        user_id = parse_int(request.match_info["user_id"], "user_id", minimum=1)
+        action = str(body.get("action") or "")
+        reason = str(body.get("reason") or "").strip()
+        profile = await decide_teacher_application(
+            user_id=user_id,
+            action=action,
+            actor_id=request["admin"]["username"],
+            actor_name=request["admin"]["username"],
+            source="web",
+            reason=reason,
+        )
+        labels = {"approve": "批准", "reject": "拒绝", "revoke": "撤销"}
+        set_audit(
+            request,
+            f"directory.application.{action}",
+            target_id=user_id,
+            summary=f"{labels.get(action, action)}老师申请",
+            metadata={"reason": reason},
+        )
+        notification = {
+            "approve": "你的老师申请已通过,可以在菜单中自助上榜。",
+            "reject": f"你的老师申请未通过。原因:{reason}",
+            "revoke": f"你的老师资格已被撤销。原因:{reason}",
+        }.get(action)
+        if notification:
+            await self._notify_directory_user(profile=profile, text=notification)
+        return success(profile)
+
+    async def directory_profiles(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        listed_raw = request.query.get("listed")
+        listed = (
+            listed_raw.lower() in {"1", "true"}
+            if listed_raw is not None
+            else None
+        )
+        items, total = await list_directory_profiles(
+            query=str(request.query.get("query") or ""),
+            application_status=str(
+                request.query.get("application_status") or APPLICATION_APPROVED
+            ),
+            listed=listed,
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {
+                "items": items,
+                "total": total,
+                "page": page,
+                "page_size": page_size,
+            }
+        )
+
+    async def directory_profile_action(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        user_id = parse_int(request.match_info["user_id"], "user_id", minimum=1)
+        action = str(body.get("action") or "")
+        reason = str(body.get("reason") or "").strip()
+        if not reason:
+            raise ApiProblem("reason_required", "强制状态操作必须填写原因。")
+        profile, _ = await set_teacher_state(
+            user_id=user_id,
+            action=action,
+            actor_id=request["admin"]["username"],
+            actor_name=request["admin"]["username"],
+            source="web",
+            reason=reason,
+        )
+        set_audit(
+            request,
+            f"directory.profile.{action}",
+            target_id=user_id,
+            summary=f"强制修改老师状态:{action}",
+            metadata={"reason": reason},
+        )
+        await self._notify_directory_user(
+            profile=profile,
+            text=f"管理员已将你的老师状态修改为“{action}”。原因:{reason}",
+        )
+        return success(profile)
+
+    async def directory_locations(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_directory_locations(
+            query=str(request.query.get("query") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        safe_items = []
+        for item in items:
+            profile = item.get("profile") or {}
+            safe_items.append(
+                {
+                    "user_id": item["user_id"],
+                    "longitude": item.get("longitude"),
+                    "latitude": item.get("latitude"),
+                    "region": item.get("region"),
+                    "geocode_consent": item.get("geocode_consent"),
+                    "geocode_status": item.get("geocode_status"),
+                    "source": item.get("source"),
+                    "updated_at": item.get("updated_at"),
+                    "display_name": profile.get("display_name"),
+                    "username": profile.get("username"),
+                    "application_status": profile.get("application_status", "none"),
+                }
+            )
+        return success(
+            {
+                "items": safe_items,
+                "total": total,
+                "page": page,
+                "page_size": page_size,
+            }
+        )
+
+    async def directory_location_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        user_id = parse_int(request.match_info["user_id"], "user_id", minimum=1)
+        reason = str(body.get("reason") or "").strip()
+        if not reason:
+            raise ApiProblem("reason_required", "修正位置必须填写原因。")
+        profile = await get_directory_profile(user_id)
+        if not profile:
+            raise ApiProblem("profile_not_found", "未找到该成员资料。", status=404)
+        previous = await get_directory_location(user_id)
+        saved = await save_directory_location(
+            user_id=user_id,
+            longitude=body.get("longitude"),
+            latitude=body.get("latitude"),
+            source="web",
+            actor_id=request["admin"]["username"],
+            actor_name=request["admin"]["username"],
+            geocode_consent=(
+                previous.get("geocode_consent") if previous else None
+            ),
+        )
+        set_audit(
+            request,
+            "directory.location.update",
+            target_id=user_id,
+            summary="修正成员位置",
+            metadata={"reason": reason},
+        )
+        return success(
+            {
+                "user_id": user_id,
+                "longitude": saved["longitude"],
+                "latitude": saved["latitude"],
+                "region": saved.get("region"),
+                "geocode_status": saved.get("geocode_status"),
+                "geocode_consent": saved.get("geocode_consent"),
+                "updated_at": saved.get("updated_at"),
+            }
+        )
+
+    async def directory_location_delete(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        user_id = parse_int(request.match_info["user_id"], "user_id", minimum=1)
+        reason = str(body.get("reason") or "").strip()
+        if not reason:
+            raise ApiProblem("reason_required", "清除位置必须填写原因。")
+        deleted = await clear_directory_location(
+            user_id=user_id,
+            actor_id=request["admin"]["username"],
+            actor_name=request["admin"]["username"],
+            source="web",
+            reason=reason,
+        )
+        set_audit(
+            request,
+            "directory.location.delete",
+            target_id=user_id,
+            summary="清除成员位置",
+            metadata={"reason": reason},
+        )
+        return success({"deleted": deleted})
+
+    async def directory_events(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_directory_events(
+            query=str(request.query.get("query") or ""),
+            event_type=str(request.query.get("event_type") or ""),
+            page=page,
+            page_size=page_size,
+        )
+        return success(
+            {
+                "items": items,
+                "total": total,
+                "page": page,
+                "page_size": page_size,
+            }
+        )
+
     def _set_session_cookie(self, response: web.Response, token: str) -> None:
         response.set_cookie(
             SESSION_COOKIE,
@@ -1363,6 +1846,16 @@ class AdminApi:
         )
 
 
+async def _start_global_admin_services(_: web.Application) -> None:
+    if not bool(getattr(wbb, "BOT_WORKER_MODE", False)):
+        await start_directory_geocoder()
+
+
+async def _stop_global_admin_services(_: web.Application) -> None:
+    if not bool(getattr(wbb, "BOT_WORKER_MODE", False)):
+        await stop_directory_geocoder()
+
+
 def build_admin_application() -> web.Application:
     max_upload_mb = int(getattr(wbb, "ADMIN_WEB_UPLOAD_MAX_MB", 20))
     application = web.Application(
@@ -1377,4 +1870,6 @@ def build_admin_application() -> web.Application:
     admin_api = AdminApi()
     application["admin_api"] = admin_api
     admin_api.register(application)
+    application.on_startup.append(_start_global_admin_services)
+    application.on_cleanup.append(_stop_global_admin_services)
     return application

+ 16 - 1
wbb/admin/supervisor.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 import asyncio
 import os
 import re
+import secrets
 import socket
 import sys
 from dataclasses import dataclass
@@ -33,9 +34,19 @@ class BotProcess:
 
 
 class BotSupervisor:
-    def __init__(self, config_path: str | Path, *, project_root: str | Path) -> None:
+    def __init__(
+        self,
+        config_path: str | Path,
+        *,
+        project_root: str | Path,
+        control_port: int | None = None,
+    ) -> None:
         self.config_path = Path(config_path).resolve()
         self.project_root = Path(project_root).resolve()
+        self.control_port = int(
+            control_port or os.environ.get("ADMIN_WEB_PORT", 8088)
+        )
+        self.internal_token = secrets.token_urlsafe(32)
         self._processes: dict[str, BotProcess] = {}
         self._lock = asyncio.Lock()
 
@@ -173,6 +184,10 @@ class BotSupervisor:
                 "WBB_BOT_PROFILE_ID": str(profile["bot_id"]),
                 "WBB_BOT_DATABASE": self._worker_database_name(profile["bot_id"]),
                 "WBB_BOT_PERMISSIONS": ",".join(profile.get("permissions", [])),
+                "WBB_SUPERVISOR_INTERNAL_URL": (
+                    f"http://127.0.0.1:{self.control_port}"
+                ),
+                "WBB_INTERNAL_TOKEN": self.internal_token,
                 "BOT_TOKEN": str(profile["bot_token"]),
                 "API_ID": str(profile["api_id"]),
                 "API_HASH": str(profile["api_hash"]),

+ 14 - 1
wbb/modules/chat_watcher.py

@@ -23,6 +23,7 @@ SOFTWARE.
 """
 from wbb import BOT_PERMISSIONS, app
 from wbb.services.bot_permissions import has_permission
+from wbb.services.directory import observe_group_member
 from wbb.services.member_identity import observe_message_member_identities
 from wbb.utils.dbadmin import upsert_managed_chat
 from wbb.utils.dbfunctions import (
@@ -50,8 +51,20 @@ async def chat_watcher_func(_, message):
 
     await add_served_chat(chat_id)
     if chat_id < 0:
-        if has_permission("automation.manage", BOT_PERMISSIONS):
+        if has_permission("automation.manage", BOT_PERMISSIONS) or has_permission(
+            "teacher_directory.manage", BOT_PERMISSIONS
+        ):
             await observe_message_member_identities(chat_id=chat_id, message=message)
+        if (
+            message.from_user
+            and not message.from_user.is_bot
+            and has_permission("teacher_directory.manage", BOT_PERMISSIONS)
+        ):
+            await observe_group_member(
+                chat_id=chat_id,
+                chat_title=message.chat.title or "",
+                user=message.from_user,
+            )
         await upsert_managed_chat(
             chat_id=chat_id,
             title=message.chat.title,

+ 1036 - 0
wbb/modules/teacher_directory.py

@@ -0,0 +1,1036 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from html import escape
+from secrets import token_urlsafe
+from typing import Any
+
+from pyrogram import StopPropagation, filters
+from pyrogram.enums import ChatMemberStatus, ParseMode
+from pyrogram.types import (
+    CallbackQuery,
+    ChatMemberUpdated,
+    InlineKeyboardButton,
+    InlineKeyboardMarkup,
+    KeyboardButton,
+    Message,
+    ReplyKeyboardMarkup,
+)
+
+from wbb import BOT_USERNAME, app, log
+from wbb.services.directory import (
+    DirectoryServiceError,
+    admin_change_teacher_state,
+    admin_decide_teacher,
+    apply_as_teacher,
+    change_own_teacher_state,
+    clear_user_location,
+    directory_for_user,
+    format_distance,
+    observe_directory_user,
+    observe_group_member,
+    profile_summary,
+    public_teacher,
+    require_platform_membership,
+    start_presence_sweeper,
+    status_value,
+    update_user_geocode_consent,
+    update_user_location,
+    verify_platform_membership,
+)
+from wbb.services.directory_geocoding import (
+    NOMINATIM_ATTRIBUTION,
+    NOMINATIM_ATTRIBUTION_URL,
+)
+from wbb.utils.dbdirectory import (
+    APPLICATION_APPROVED,
+    APPLICATION_PENDING,
+    APPLICATION_REJECTED,
+    APPLICATION_REVOKED,
+    get_directory_location,
+    get_directory_profile,
+    list_directory_profiles,
+    list_teacher_applications,
+)
+
+__MODULE__ = "师生与附近"
+__HELP__ = """/directory - 打开师生服务菜单或在群内验证成员身份。
+/nearby - 选择范围并查找附近老师。
+/teacher_rank - 按距离查看全部上榜老师。
+/teacher_apply - 申请成为老师。
+/teacher_online - 老师上线。
+/teacher_offline - 老师下线。
+/teacher_list - 老师上榜。
+/teacher_unlist - 老师下榜。"""
+
+APPLICATION_LABELS = {
+    "none": "学生",
+    APPLICATION_PENDING: "老师申请待审批",
+    APPLICATION_APPROVED: "老师",
+    APPLICATION_REJECTED: "老师申请已拒绝",
+    APPLICATION_REVOKED: "老师资格已撤销",
+}
+TEACHER_ACTION_LABELS = {
+    "list": "上榜",
+    "unlist": "下榜",
+    "online": "上线",
+    "offline": "下线",
+}
+BUTTON_NEARBY = "附近老师"
+BUTTON_RANK = "老师榜单"
+BUTTON_APPLY = "申请成为老师"
+BUTTON_APPROVAL = "老师审批"
+BUTTON_STATUS = "我的状态"
+BUTTON_LIST = "上榜"
+BUTTON_UNLIST = "下榜"
+BUTTON_ONLINE = "上线"
+BUTTON_OFFLINE = "下线"
+BUTTON_PRIVACY = "位置隐私"
+BUTTON_CLEAR_LOCATION = "清除位置"
+
+_admin_flows: dict[int, dict[str, Any]] = {}
+_admin_confirmations: dict[str, dict[str, Any]] = {}
+
+
+def _display_name(profile: dict[str, Any]) -> str:
+    return str(profile.get("display_name") or f"用户 {profile['user_id']}")
+
+
+def _directory_keyboard(
+    profile: dict[str, Any] | None,
+    *,
+    is_admin: bool,
+) -> ReplyKeyboardMarkup:
+    status = str((profile or {}).get("application_status") or "none")
+    rows: list[list[KeyboardButton]] = [
+        [KeyboardButton("更新位置", request_location=True)],
+        [KeyboardButton(BUTTON_NEARBY), KeyboardButton(BUTTON_RANK)],
+    ]
+    if status == APPLICATION_APPROVED:
+        rows.extend(
+            [
+                [
+                    KeyboardButton(
+                        BUTTON_UNLIST if profile and profile.get("listed") else BUTTON_LIST
+                    ),
+                    KeyboardButton(
+                        BUTTON_OFFLINE
+                        if profile and profile.get("online")
+                        else BUTTON_ONLINE
+                    ),
+                ],
+                [KeyboardButton(BUTTON_STATUS)],
+            ]
+        )
+    else:
+        rows.append([KeyboardButton(BUTTON_APPLY), KeyboardButton(BUTTON_STATUS)])
+    rows.append([KeyboardButton(BUTTON_PRIVACY), KeyboardButton(BUTTON_CLEAR_LOCATION)])
+    if is_admin:
+        rows.append([KeyboardButton(BUTTON_APPROVAL)])
+    return ReplyKeyboardMarkup(
+        rows,
+        resize_keyboard=True,
+        is_persistent=True,
+        selective=True,
+        placeholder="选择师生服务或输入消息",
+    )
+
+
+async def send_directory_menu(message: Message) -> None:
+    if not message.from_user:
+        return
+    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)
+    status_label = APPLICATION_LABELS.get(summary["application_status"], "学生")
+    lines = [
+        "<b>师生与附近</b>",
+        f"身份:{escape(status_label)}",
+        f"位置:{escape(summary['region'] or '尚未更新')}",
+    ]
+    if summary["application_status"] == APPLICATION_APPROVED:
+        lines.extend(
+            [
+                f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
+                f"状态:{'在线' if summary['online'] else '离线'}",
+            ]
+        )
+    await message.reply_text(
+        "\n".join(lines),
+        parse_mode=ParseMode.HTML,
+        reply_markup=_directory_keyboard(
+            {**profile, "online": summary["online"]},
+            is_admin=bool(admin_result.get("allowed")),
+        ),
+    )
+
+
+async def _verify_group_member(message: Message) -> bool:
+    if not message.from_user:
+        return False
+    try:
+        member = await app.get_chat_member(message.chat.id, message.from_user.id)
+    except Exception:
+        await message.reply_text("暂时无法验证你的群成员身份,请稍后重试。")
+        return False
+    active = member.status in {
+        ChatMemberStatus.OWNER,
+        ChatMemberStatus.ADMINISTRATOR,
+        ChatMemberStatus.MEMBER,
+        ChatMemberStatus.RESTRICTED,
+    }
+    await observe_group_member(
+        chat_id=message.chat.id,
+        chat_title=message.chat.title or "",
+        user=message.from_user,
+        status=status_value(member.status),
+        active=active,
+        verified=True,
+    )
+    if not active:
+        await message.reply_text("该功能仅对当前群成员开放。")
+    return active
+
+
+@app.on_message(filters.command("directory"))
+async def directory_command(_, message: Message):
+    if message.chat.type.value == "private":
+        return await send_directory_menu(message)
+    if not await _verify_group_member(message):
+        return
+    url = f"https://t.me/{BOT_USERNAME}?start=directory"
+    await message.reply_text(
+        "身份验证成功。请点击下方按钮在私聊中使用师生与附近功能。",
+        reply_markup=InlineKeyboardMarkup(
+            [[InlineKeyboardButton("打开师生服务", url=url)]]
+        ),
+    )
+
+
+@app.on_message(filters.command("nearby") & filters.private)
+async def nearby_command(_, message: Message):
+    await _show_radius_picker(message)
+
+
+@app.on_message(filters.command("teacher_rank") & filters.private)
+async def teacher_rank_command(_, message: Message):
+    await _show_teacher_results(message, radius_km=None, page=1)
+
+
+@app.on_message(filters.command("teacher_apply") & filters.private)
+async def teacher_apply_command(_, message: Message):
+    await _apply_teacher(message)
+
+
+async def _self_state_command(message: Message, action: str) -> None:
+    if not message.from_user:
+        return
+    try:
+        profile, _ = await change_own_teacher_state(
+            user=message.from_user,
+            action=action,
+        )
+        await message.reply_text(
+            f"已{TEACHER_ACTION_LABELS[action]}。"
+            + (
+                "本次上线状态将在 24 小时后自动失效。"
+                if action == "online"
+                else ""
+            )
+        )
+        await _refresh_keyboard(message, profile)
+    except DirectoryServiceError as exc:
+        await message.reply_text(str(exc))
+
+
+@app.on_message(filters.command("teacher_online") & filters.private)
+async def teacher_online_command(_, message: Message):
+    await _self_state_command(message, "online")
+
+
+@app.on_message(filters.command("teacher_offline") & filters.private)
+async def teacher_offline_command(_, message: Message):
+    await _self_state_command(message, "offline")
+
+
+@app.on_message(filters.command("teacher_list") & filters.private)
+async def teacher_list_command(_, message: Message):
+    await _self_state_command(message, "list")
+
+
+@app.on_message(filters.command("teacher_unlist") & filters.private)
+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:
+        location, previous_consent = await update_user_location(
+            user=message.from_user,
+            longitude=message.location.longitude,
+            latitude=message.location.latitude,
+        )
+    except DirectoryServiceError as exc:
+        return await message.reply_text(str(exc))
+    await message.reply_text("位置已更新,可立即使用附近老师和老师榜单。")
+    if previous_consent is None:
+        await message.reply_text(
+            "是否同意将本次及以后更新的精确坐标发送给 "
+            "OpenStreetMap Nominatim,仅用于解析省、市、区县?\n\n"
+            "不会发送 Telegram 用户信息;选择“仅计算距离”后,附近功能仍可正常使用。",
+            reply_markup=InlineKeyboardMarkup(
+                [
+                    [
+                        InlineKeyboardButton(
+                            "同意解析区域",
+                            callback_data="dir:consent:yes",
+                        ),
+                        InlineKeyboardButton(
+                            "仅计算距离",
+                            callback_data="dir:consent:no",
+                        ),
+                    ]
+                ]
+            ),
+        )
+    elif previous_consent is True:
+        await message.reply_text("区域解析已进入队列,完成前会显示“区域暂不可用”。")
+    await _refresh_keyboard(message, await get_directory_profile(message.from_user.id))
+
+
+async def _refresh_keyboard(
+    message: Message,
+    profile: dict[str, Any] | None,
+) -> None:
+    if not message.from_user:
+        return
+    admin_result = await verify_platform_membership(
+        user_id=int(message.from_user.id),
+        require_admin=True,
+    )
+    summary = profile_summary(
+        profile or {"user_id": message.from_user.id},
+        await get_directory_location(message.from_user.id),
+    )
+    await message.reply_text(
+        "菜单已更新。",
+        reply_markup=_directory_keyboard(
+            {**(profile or {}), "online": summary["online"]},
+            is_admin=bool(admin_result.get("allowed")),
+        ),
+    )
+
+
+async def _apply_teacher(message: Message) -> None:
+    if not message.from_user:
+        return
+    try:
+        profile = await apply_as_teacher(user=message.from_user)
+    except DirectoryServiceError as exc:
+        return await message.reply_text(str(exc))
+    await message.reply_text(
+        "老师申请已提交,管理员审批后会通过申请来源机器人通知你。"
+        if profile.get("application_status") == APPLICATION_PENDING
+        else "当前申请状态未发生变化。"
+    )
+    await _refresh_keyboard(message, profile)
+
+
+async def _show_status(message: Message) -> None:
+    if not message.from_user:
+        return
+    profile = await observe_directory_user(message.from_user)
+    summary = profile_summary(profile, await get_directory_location(message.from_user.id))
+    lines = [
+        "<b>我的师生状态</b>",
+        f"身份:{escape(APPLICATION_LABELS.get(summary['application_status'], '学生'))}",
+        f"位置:{escape(summary['region'] or '尚未更新')}",
+    ]
+    if summary["application_status"] == APPLICATION_APPROVED:
+        lines.extend(
+            [
+                f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
+                f"上下线:{'在线' if summary['online'] else '离线'}",
+                (
+                    "自动下线:"
+                    + escape(
+                        summary["online_until"].astimezone(UTC).strftime(
+                            "%Y-%m-%d %H:%M UTC"
+                        )
+                    )
+                    if summary["online"] and summary["online_until"]
+                    else ""
+                ),
+            ]
+        )
+    await message.reply_text(
+        "\n".join(line for line in lines if line),
+        parse_mode=ParseMode.HTML,
+    )
+
+
+async def _show_privacy(message: Message) -> None:
+    if not message.from_user:
+        return
+    location = await get_directory_location(message.from_user.id)
+    if not location:
+        return await message.reply_text("尚未保存位置。")
+    consent = location.get("geocode_consent")
+    text = {
+        True: "当前已同意将精确坐标发送给 Nominatim 解析行政区域。",
+        False: "当前只在本系统保存位置并计算距离,不向地图服务发送坐标。",
+        None: "尚未选择是否使用 Nominatim 解析行政区域。",
+    }[consent]
+    await message.reply_text(
+        text
+        + "\n关闭授权只影响后续请求;第三方已接收的历史请求无法撤回。",
+        reply_markup=InlineKeyboardMarkup(
+            [
+                [
+                    InlineKeyboardButton("同意区域解析", callback_data="dir:consent:yes"),
+                    InlineKeyboardButton("关闭区域解析", callback_data="dir:consent:no"),
+                ]
+            ]
+        ),
+    )
+
+
+async def _show_radius_picker(target: Message | CallbackQuery) -> None:
+    markup = InlineKeyboardMarkup(
+        [
+            [
+                InlineKeyboardButton(
+                    f"{radius} 公里",
+                    callback_data=f"dir:list:{radius}:1",
+                )
+                for radius in (5, 10)
+            ],
+            [
+                InlineKeyboardButton(
+                    f"{radius} 公里",
+                    callback_data=f"dir:list:{radius}:1",
+                )
+                for radius in (20, 50)
+            ],
+        ]
+    )
+    if isinstance(target, CallbackQuery):
+        await target.message.edit_text("请选择附近老师的搜索范围:", reply_markup=markup)
+    else:
+        await target.reply_text("请选择附近老师的搜索范围:", reply_markup=markup)
+
+
+def _teacher_result_markup(
+    teachers: list[dict[str, Any]],
+    *,
+    radius_key: str,
+    page: int,
+    total: int,
+) -> InlineKeyboardMarkup:
+    rows: list[list[InlineKeyboardButton]] = []
+    for item in teachers:
+        username = str(item.get("username") or "")
+        label = f"{item['display_name']} (@{username})"
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    label[:48],
+                    url=f"https://t.me/{username}",
+                )
+            ]
+        )
+    navigation: list[InlineKeyboardButton] = []
+    if page > 1:
+        navigation.append(
+            InlineKeyboardButton(
+                "上一页",
+                callback_data=f"dir:list:{radius_key}:{page - 1}",
+            )
+        )
+    if page * 10 < total:
+        navigation.append(
+            InlineKeyboardButton(
+                "下一页",
+                callback_data=f"dir:list:{radius_key}:{page + 1}",
+            )
+        )
+    if navigation:
+        rows.append(navigation)
+    rows.append([InlineKeyboardButton("切换附近范围", callback_data="dir:radius")])
+    return InlineKeyboardMarkup(rows)
+
+
+async def _show_teacher_results(
+    target: Message | CallbackQuery,
+    *,
+    radius_km: int | None,
+    page: int,
+) -> None:
+    user = target.from_user
+    try:
+        raw_items, total = await directory_for_user(
+            user=user,
+            radius_km=radius_km,
+            page=page,
+            page_size=10,
+        )
+    except DirectoryServiceError as exc:
+        if isinstance(target, CallbackQuery):
+            return await target.answer(str(exc), show_alert=True)
+        return await target.reply_text(str(exc))
+    teachers = [public_teacher(item) for item in raw_items]
+    title = (
+        f"<b>{radius_km} 公里内的老师</b>"
+        if radius_km is not None
+        else "<b>老师榜单</b>"
+    )
+    lines = [title, f"共 {total} 位已上榜老师,按距离排序。"]
+    for index, item in enumerate(teachers, start=(page - 1) * 10 + 1):
+        lines.append(
+            f"\n{index}. <b>{escape(item['display_name'])}</b> "
+            f"(@{escape(item['username'])})\n"
+            f"状态:{'在线' if item['online'] else '离线'} | "
+            f"距离:{format_distance(item['distance_meters'])} | "
+            f"区域:{escape(item['region'])}"
+        )
+    if not teachers:
+        lines.append("\n当前范围内没有已上榜老师。")
+    lines.append(
+        f'\n<a href="{NOMINATIM_ATTRIBUTION_URL}">{NOMINATIM_ATTRIBUTION}</a>'
+    )
+    markup = _teacher_result_markup(
+        teachers,
+        radius_key=str(radius_km) if radius_km is not None else "all",
+        page=page,
+        total=total,
+    )
+    if isinstance(target, CallbackQuery):
+        await target.message.edit_text(
+            "\n".join(lines),
+            parse_mode=ParseMode.HTML,
+            disable_web_page_preview=True,
+            reply_markup=markup,
+        )
+        await target.answer()
+    else:
+        await target.reply_text(
+            "\n".join(lines),
+            parse_mode=ParseMode.HTML,
+            disable_web_page_preview=True,
+            reply_markup=markup,
+        )
+
+
+async def _show_admin_applications(
+    target: Message | CallbackQuery,
+    *,
+    page: int = 1,
+) -> None:
+    try:
+        await require_platform_membership(int(target.from_user.id), require_admin=True)
+    except DirectoryServiceError as exc:
+        if isinstance(target, CallbackQuery):
+            return await target.answer(str(exc), show_alert=True)
+        return await target.reply_text(str(exc))
+    items, total = await list_teacher_applications(
+        status=APPLICATION_PENDING,
+        page=page,
+        page_size=5,
+    )
+    lines = ["<b>老师申请审批</b>", f"待审批:{total}"]
+    rows: list[list[InlineKeyboardButton]] = []
+    for item in items:
+        lines.append(
+            f"\n{escape(_display_name(item))} (@{escape(item.get('username') or '')})"
+            f"\n用户 ID:<code>{item['user_id']}</code>"
+        )
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    f"查看 {_display_name(item)[:18]}",
+                    callback_data=f"dir:admin:detail:{item['user_id']}",
+                )
+            ]
+        )
+    if page > 1:
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    "上一页",
+                    callback_data=f"dir:admin:pending:{page - 1}",
+                )
+            ]
+        )
+    if page * 5 < total:
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    "下一页",
+                    callback_data=f"dir:admin:pending:{page + 1}",
+                )
+            ]
+        )
+    rows.append(
+        [InlineKeyboardButton("已批准老师", callback_data="dir:admin:teachers:1")]
+    )
+    markup = InlineKeyboardMarkup(rows)
+    if isinstance(target, CallbackQuery):
+        await target.message.edit_text(
+            "\n".join(lines),
+            parse_mode=ParseMode.HTML,
+            reply_markup=markup,
+        )
+        await target.answer()
+    else:
+        await target.reply_text(
+            "\n".join(lines),
+            parse_mode=ParseMode.HTML,
+            reply_markup=markup,
+        )
+
+
+async def _show_admin_teachers(target: CallbackQuery, page: int) -> None:
+    try:
+        await require_platform_membership(
+            int(target.from_user.id),
+            require_admin=True,
+        )
+    except DirectoryServiceError as exc:
+        return await target.answer(str(exc), show_alert=True)
+    items, total = await list_directory_profiles(
+        application_status=APPLICATION_APPROVED,
+        page=page,
+        page_size=8,
+    )
+    rows = [
+        [
+            InlineKeyboardButton(
+                _display_name(item)[:28],
+                callback_data=f"dir:admin:detail:{item['user_id']}",
+            )
+        ]
+        for item in items
+    ]
+    navigation: list[InlineKeyboardButton] = []
+    if page > 1:
+        navigation.append(
+            InlineKeyboardButton(
+                "上一页",
+                callback_data=f"dir:admin:teachers:{page - 1}",
+            )
+        )
+    if page * 8 < total:
+        navigation.append(
+            InlineKeyboardButton(
+                "下一页",
+                callback_data=f"dir:admin:teachers:{page + 1}",
+            )
+        )
+    if navigation:
+        rows.append(navigation)
+    rows.append([InlineKeyboardButton("待审批", callback_data="dir:admin:pending:1")])
+    await target.message.edit_text(
+        f"<b>已批准老师</b>\n共 {total} 人",
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(rows),
+    )
+    await target.answer()
+
+
+async def _show_admin_teacher_detail(target: CallbackQuery, user_id: int) -> None:
+    try:
+        await require_platform_membership(
+            int(target.from_user.id),
+            require_admin=True,
+        )
+    except DirectoryServiceError as exc:
+        return await target.answer(str(exc), show_alert=True)
+    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))
+    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"位置:{escape(summary['region'] or '尚未更新')}",
+        f"榜单:{'已上榜' if summary['listed'] else '未上榜'}",
+        f"状态:{'在线' if summary['online'] else '离线'}",
+    ]
+    rows: list[list[InlineKeyboardButton]] = []
+    if summary["application_status"] == APPLICATION_PENDING:
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    "批准",
+                    callback_data=f"dir:admin:approveask:{user_id}",
+                ),
+                InlineKeyboardButton(
+                    "拒绝",
+                    callback_data=f"dir:admin:flow:reject:{user_id}",
+                ),
+            ]
+        )
+    if summary["application_status"] == APPLICATION_APPROVED:
+        rows.extend(
+            [
+                [
+                    InlineKeyboardButton(
+                        "强制下榜" if summary["listed"] else "强制上榜",
+                        callback_data=(
+                            f"dir:admin:flow:"
+                            f"{'unlist' if summary['listed'] else 'list'}:{user_id}"
+                        ),
+                    ),
+                    InlineKeyboardButton(
+                        "强制下线" if summary["online"] else "强制上线",
+                        callback_data=(
+                            f"dir:admin:flow:"
+                            f"{'offline' if summary['online'] else 'online'}:{user_id}"
+                        ),
+                    ),
+                ],
+                [
+                    InlineKeyboardButton(
+                        "撤销老师资格",
+                        callback_data=f"dir:admin:flow:revoke:{user_id}",
+                    )
+                ],
+            ]
+        )
+    rows.append([InlineKeyboardButton("返回审批", callback_data="dir:admin:pending:1")])
+    await target.message.edit_text(
+        "\n".join(lines),
+        parse_mode=ParseMode.HTML,
+        reply_markup=InlineKeyboardMarkup(rows),
+    )
+    try:
+        await target.answer()
+    except Exception:
+        pass
+
+
+async def _start_admin_reason_flow(
+    target: CallbackQuery,
+    *,
+    action: str,
+    user_id: int,
+) -> None:
+    try:
+        await require_platform_membership(
+            int(target.from_user.id),
+            require_admin=True,
+        )
+    except DirectoryServiceError as exc:
+        return await target.answer(str(exc), show_alert=True)
+    _admin_flows[int(target.from_user.id)] = {
+        "action": action,
+        "user_id": int(user_id),
+        "expires_at": datetime.now(UTC) + timedelta(minutes=10),
+    }
+    await target.message.reply_text(
+        f"请输入“{TEACHER_ACTION_LABELS.get(action, action)}”的原因,"
+        "发送 /cancel 可取消。"
+    )
+    await target.answer()
+
+
+async def _confirm_admin_action(target: CallbackQuery, token: str) -> None:
+    confirmation = _admin_confirmations.get(token)
+    if not confirmation or confirmation["actor_id"] != target.from_user.id:
+        return await target.answer("确认已失效,请重新操作。", show_alert=True)
+    if confirmation["expires_at"] < datetime.now(UTC):
+        _admin_confirmations.pop(token, None)
+        return await target.answer("确认已超时,请重新操作。", show_alert=True)
+    action = confirmation["action"]
+    user_id = int(confirmation["user_id"])
+    reason = str(confirmation.get("reason") or "")
+    try:
+        if action in {"reject", "revoke"}:
+            profile = await admin_decide_teacher(
+                actor=target.from_user,
+                user_id=user_id,
+                action=action,
+                reason=reason,
+            )
+        else:
+            profile = await admin_change_teacher_state(
+                actor=target.from_user,
+                user_id=user_id,
+                action=action,
+                reason=reason,
+            )
+        with_reason = f"\n原因:{reason}" if reason else ""
+        with_suppressed_notification = (
+            "老师申请已被拒绝" if action == "reject" else
+            "老师资格已被撤销" if action == "revoke" else
+            f"老师状态已被管理员修改为“{TEACHER_ACTION_LABELS[action]}”"
+        )
+        try:
+            await app.send_message(
+                user_id,
+                f"{with_suppressed_notification}。{with_reason}",
+            )
+        except Exception:
+            pass
+        _admin_confirmations.pop(token, None)
+        await target.answer("操作成功。")
+        await _show_admin_teacher_detail(target, int(profile["user_id"]))
+    except DirectoryServiceError as exc:
+        await target.answer(str(exc), show_alert=True)
+
+
+@app.on_callback_query(filters.regex(r"^dir:"))
+async def directory_callback(_, query: CallbackQuery):
+    parts = str(query.data).split(":")
+    try:
+        if parts[1] == "consent":
+            consent = parts[2] == "yes"
+            await update_user_geocode_consent(
+                user=query.from_user,
+                consent=consent,
+            )
+            await query.message.edit_text(
+                "已同意区域解析,当前位置已进入解析队列。"
+                if consent
+                else "已关闭区域解析,位置只用于本地距离计算。"
+            )
+            return await query.answer("设置已保存。")
+        if parts[1] == "radius":
+            return await _show_radius_picker(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]),
+            )
+        if parts[1] == "clearask":
+            return await query.message.edit_text(
+                "清除位置后会自动下榜并下线,确认继续吗?",
+                reply_markup=InlineKeyboardMarkup(
+                    [
+                        [
+                            InlineKeyboardButton(
+                                "确认清除",
+                                callback_data="dir:clear",
+                            ),
+                            InlineKeyboardButton("取消", callback_data="dir:cancel"),
+                        ]
+                    ]
+                ),
+            )
+        if parts[1] == "clear":
+            await clear_user_location(user=query.from_user)
+            await query.message.edit_text("位置已清除,老师状态已同步下线并下榜。")
+            return await query.answer("已清除。")
+        if parts[1] == "cancel":
+            await query.message.edit_text("已取消。")
+            return await query.answer()
+        if parts[1] == "admin":
+            if parts[2] == "pending":
+                return await _show_admin_applications(query, page=int(parts[3]))
+            if parts[2] == "teachers":
+                return await _show_admin_teachers(query, int(parts[3]))
+            if parts[2] == "detail":
+                return await _show_admin_teacher_detail(query, int(parts[3]))
+            if parts[2] == "approveask":
+                user_id = int(parts[3])
+                return await query.message.edit_text(
+                    "批准后老师身份将在全部群和 Bot 中生效,确认继续吗?",
+                    reply_markup=InlineKeyboardMarkup(
+                        [
+                            [
+                                InlineKeyboardButton(
+                                    "确认批准",
+                                    callback_data=f"dir:admin:approve:{user_id}",
+                                ),
+                                InlineKeyboardButton(
+                                    "取消",
+                                    callback_data=f"dir:admin:detail:{user_id}",
+                                ),
+                            ]
+                        ]
+                    ),
+                )
+            if parts[2] == "approve":
+                profile = await admin_decide_teacher(
+                    actor=query.from_user,
+                    user_id=int(parts[3]),
+                    action="approve",
+                )
+                try:
+                    await app.send_message(
+                        int(profile["user_id"]),
+                        "你的老师申请已通过,可以在菜单中自助上榜。",
+                    )
+                except Exception:
+                    pass
+                await query.answer("已批准。")
+                return await _show_admin_teacher_detail(
+                    query,
+                    int(profile["user_id"]),
+                )
+            if parts[2] == "flow":
+                return await _start_admin_reason_flow(
+                    query,
+                    action=parts[3],
+                    user_id=int(parts[4]),
+                )
+            if parts[2] == "confirm":
+                return await _confirm_admin_action(query, parts[3])
+    except (DirectoryServiceError, ValueError) as exc:
+        return await query.answer(str(exc), show_alert=True)
+    except Exception as exc:
+        log.error(f"师生目录回调失败:{exc}")
+        return await query.answer("操作失败,请稍后重试。", show_alert=True)
+
+
+@app.on_message(filters.command("cancel") & filters.private, group=-20)
+async def cancel_directory_flow(_, message: Message):
+    flow = _admin_flows.pop(message.from_user.id, None) if message.from_user else None
+    if not flow:
+        return
+    await message.reply_text("已取消当前老师管理操作。")
+    raise StopPropagation
+
+
+@app.on_message(filters.private & filters.text, group=13)
+async def directory_private_text(_, message: Message):
+    if not message.from_user:
+        return
+    flow = _admin_flows.get(message.from_user.id)
+    if flow:
+        if flow["expires_at"] < datetime.now(UTC):
+            _admin_flows.pop(message.from_user.id, None)
+            return await message.reply_text("操作已超时,请重新打开老师审批。")
+        reason = str(message.text or "").strip()
+        if not reason:
+            return await message.reply_text("原因不能为空,请重新输入。")
+        token = token_urlsafe(8)
+        _admin_confirmations[token] = {
+            **flow,
+            "actor_id": message.from_user.id,
+            "reason": reason,
+            "expires_at": datetime.now(UTC) + timedelta(minutes=10),
+        }
+        _admin_flows.pop(message.from_user.id, None)
+        return await message.reply_text(
+            f"确认执行“{TEACHER_ACTION_LABELS.get(flow['action'], flow['action'])}”吗?"
+            f"\n用户 ID:{flow['user_id']}\n原因:{escape(reason)}",
+            reply_markup=InlineKeyboardMarkup(
+                [
+                    [
+                        InlineKeyboardButton(
+                            "确认执行",
+                            callback_data=f"dir:admin:confirm:{token}",
+                        ),
+                        InlineKeyboardButton("取消", callback_data="dir:cancel"),
+                    ]
+                ]
+            ),
+        )
+    text = str(message.text or "").strip()
+    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_APPLY:
+        return await _apply_teacher(message)
+    if text == BUTTON_STATUS:
+        return await _show_status(message)
+    if text == BUTTON_PRIVACY:
+        return await _show_privacy(message)
+    if text == BUTTON_CLEAR_LOCATION:
+        return await message.reply_text(
+            "清除位置后会自动下榜并下线,确认继续吗?",
+            reply_markup=InlineKeyboardMarkup(
+                [
+                    [
+                        InlineKeyboardButton("确认清除", callback_data="dir:clear"),
+                        InlineKeyboardButton("取消", callback_data="dir:cancel"),
+                    ]
+                ]
+            ),
+        )
+    if text == BUTTON_APPROVAL:
+        return await _show_admin_applications(message)
+    action = {
+        BUTTON_LIST: "list",
+        BUTTON_UNLIST: "unlist",
+        BUTTON_ONLINE: "online",
+        BUTTON_OFFLINE: "offline",
+    }.get(text)
+    if action:
+        return await _self_state_command(message, action)
+
+
+@app.on_message(filters.group & filters.text, group=13)
+async def teacher_presence_group_text(_, message: Message):
+    if not message.from_user:
+        return
+    text = str(message.text or "").strip()
+    action = {"上线": "online", "下线": "offline"}.get(text)
+    if not action:
+        return
+    profile = await get_directory_profile(message.from_user.id)
+    if not profile or profile.get("application_status") != APPLICATION_APPROVED:
+        return
+    if not await _verify_group_member(message):
+        return
+    try:
+        _, applied = await change_own_teacher_state(
+            user=message.from_user,
+            action=action,
+            source="telegram_group",
+            idempotency_key=(
+                f"teacher-presence:{message.chat.id}:{message.id}:"
+                f"{message.from_user.id}"
+            ),
+            chat_id=message.chat.id,
+        )
+        if applied:
+            await message.reply_text(
+                f"全平台状态已切换为{'在线' if action == 'online' else '离线'}。"
+                + ("24 小时后将自动下线。" if action == "online" else "")
+            )
+    except DirectoryServiceError as exc:
+        await message.reply_text(str(exc))
+
+
+@app.on_chat_member_updated(group=13)
+async def directory_chat_member_updated(_, update: ChatMemberUpdated):
+    member = update.new_chat_member or update.old_chat_member
+    if not member or not member.user or member.user.is_bot:
+        return
+    status = update.new_chat_member.status if update.new_chat_member else ChatMemberStatus.LEFT
+    active = status in {
+        ChatMemberStatus.OWNER,
+        ChatMemberStatus.ADMINISTRATOR,
+        ChatMemberStatus.MEMBER,
+        ChatMemberStatus.RESTRICTED,
+    }
+    await observe_group_member(
+        chat_id=update.chat.id,
+        chat_title=update.chat.title or "",
+        user=member.user,
+        status=status_value(status),
+        active=active,
+        verified=True,
+    )
+
+
+start_presence_sweeper()

+ 20 - 2
wbb/services/bot_permissions.py

@@ -37,11 +37,17 @@ PERMISSIONS = (
     BotPermission("points.manage", "积分", "社区运营", "运行积分规则、排行和人工调整。"),
     BotPermission("giveaways.manage", "抽奖", "社区运营", "创建、开奖、退款和管理参与者。"),
     BotPermission("karma.manage", "声望", "社区运营", "处理点赞、点踩和声望排行。"),
+    BotPermission(
+        "teacher_directory.manage",
+        "师生与附近",
+        "社区运营",
+        "提供老师申请、上下榜、在线状态、位置和附近老师目录。",
+    ),
     BotPermission("media.upload", "媒体中转", "系统能力", "向媒体中转群上传文件。"),
 )
 
 ALL_BOT_PERMISSIONS = frozenset(item.key for item in PERMISSIONS)
-PRIVATE_MANAGEMENT_PERMISSIONS = frozenset(
+GROUP_MANAGEMENT_PERMISSIONS = frozenset(
     {
         "chat.announcements",
         "chat.members",
@@ -51,6 +57,9 @@ PRIVATE_MANAGEMENT_PERMISSIONS = frozenset(
         "giveaways.manage",
     }
 )
+PRIVATE_MANAGEMENT_PERMISSIONS = GROUP_MANAGEMENT_PERMISSIONS | {
+    "teacher_directory.manage"
+}
 
 BUILTIN_ROLES = (
     BuiltinBotRole(
@@ -97,12 +106,18 @@ BUILTIN_ROLES = (
         "负责点赞、点踩和声望排行。",
         ("karma.manage",),
     ),
+    BuiltinBotRole(
+        "teacher_directory_manager",
+        "师生目录专员",
+        "负责老师申请、位置、上下榜、在线状态和附近老师目录。",
+        ("teacher_directory.manage",),
+    ),
 )
 
 MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
     "admin": frozenset({"chat.members", "chat.announcements", "chat.invites"}),
     "admin_misc": frozenset({"chat.profile", "chat.members"}),
-    "admin_panel": PRIVATE_MANAGEMENT_PERMISSIONS,
+    "admin_panel": GROUP_MANAGEMENT_PERMISSIONS,
     "antiservice": frozenset({"automation.manage"}),
     "blacklist": frozenset({"automation.manage"}),
     "blacklist_chat": frozenset({"automation.manage"}),
@@ -116,6 +131,7 @@ MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
     "notes": frozenset({"chat.notes"}),
     "points": frozenset({"points.manage"}),
     "rules": frozenset({"chat.rules"}),
+    "teacher_directory": frozenset({"teacher_directory.manage"}),
 }
 
 TELEGRAM_COMMAND_PERMISSIONS = {
@@ -198,6 +214,8 @@ def module_allowed(module: str, permissions: Iterable[str] | None) -> bool:
 
 
 def api_permission(method: str, path: str) -> str | None:
+    if path.startswith("/api/admin/v1/directory"):
+        return "teacher_directory.manage"
     if path.startswith("/api/admin/v1/media"):
         return "media.upload"
     if path.startswith("/api/admin/v1/points"):

+ 475 - 0
wbb/services/directory.py

@@ -0,0 +1,475 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import suppress
+from typing import Any
+
+from aiohttp import ClientError, ClientSession, ClientTimeout
+from pyrogram.enums import ChatMemberStatus
+
+import wbb
+from wbb import BOT_PROFILE_ID, SUDOERS, app, log
+from wbb.utils.dbdirectory import (
+    DirectoryDataError,
+    aware_utc,
+    clear_directory_location,
+    decide_teacher_application,
+    expire_teacher_presence,
+    get_directory_location,
+    list_directory_teachers,
+    list_membership_candidates,
+    save_directory_location,
+    set_geocode_consent,
+    set_teacher_state,
+    submit_teacher_application,
+    teacher_is_online,
+    upsert_directory_identity,
+    upsert_directory_membership,
+)
+
+ACTIVE_MEMBER_STATUSES = {
+    ChatMemberStatus.OWNER,
+    ChatMemberStatus.ADMINISTRATOR,
+    ChatMemberStatus.MEMBER,
+    ChatMemberStatus.RESTRICTED,
+}
+ADMIN_MEMBER_STATUSES = {
+    ChatMemberStatus.OWNER,
+    ChatMemberStatus.ADMINISTRATOR,
+}
+
+_presence_task: asyncio.Task[None] | None = None
+
+
+class DirectoryServiceError(RuntimeError):
+    def __init__(self, code: str, message: str, *, status: int = 400):
+        super().__init__(message)
+        self.code = code
+        self.status = status
+
+
+def status_value(value: Any) -> str:
+    return str(getattr(value, "value", value or "")).lower()
+
+
+def is_sudoer(user_id: int) -> bool:
+    try:
+        return int(user_id) in SUDOERS
+    except (TypeError, ValueError):
+        return False
+
+
+def user_identity(user: Any) -> dict[str, Any]:
+    return {
+        "user_id": int(user.id),
+        "username": getattr(user, "username", None),
+        "first_name": getattr(user, "first_name", None),
+        "last_name": getattr(user, "last_name", None),
+        "display_name": " ".join(
+            value.strip()
+            for value in (
+                getattr(user, "first_name", None),
+                getattr(user, "last_name", None),
+            )
+            if value and value.strip()
+        )
+        or (
+            f"@{user.username}"
+            if getattr(user, "username", None)
+            else f"用户 {user.id}"
+        ),
+    }
+
+
+async def observe_directory_user(user: Any, *, source: str = "telegram") -> dict[str, Any]:
+    identity = user_identity(user)
+    return await upsert_directory_identity(
+        user_id=identity["user_id"],
+        username=identity["username"],
+        first_name=identity["first_name"],
+        last_name=identity["last_name"],
+        source=source,
+    )
+
+
+async def observe_group_member(
+    *,
+    chat_id: int,
+    chat_title: str,
+    user: Any,
+    status: str = "member",
+    active: bool = True,
+    verified: bool = False,
+) -> dict[str, Any]:
+    identity = user_identity(user)
+    await observe_directory_user(user)
+    return await upsert_directory_membership(
+        bot_id=str(BOT_PROFILE_ID),
+        chat_id=int(chat_id),
+        user_id=identity["user_id"],
+        status=status,
+        active=active,
+        chat_title=chat_title,
+        username=identity["username"],
+        display_name_value=identity["display_name"],
+        verified=verified,
+    )
+
+
+async def verify_local_membership(
+    *,
+    user_id: int,
+    chat_ids: list[int],
+    require_admin: bool = False,
+) -> dict[str, Any]:
+    accepted = ADMIN_MEMBER_STATUSES if require_admin else ACTIVE_MEMBER_STATUSES
+    for chat_id in dict.fromkeys(int(value) for value in chat_ids):
+        try:
+            member = await app.get_chat_member(int(chat_id), int(user_id))
+        except Exception:
+            continue
+        status = getattr(member, "status", None)
+        active = status in ACTIVE_MEMBER_STATUSES
+        user = getattr(member, "user", None)
+        if user is not None:
+            with suppress(Exception):
+                await observe_group_member(
+                    chat_id=int(chat_id),
+                    chat_title="",
+                    user=user,
+                    status=status_value(status),
+                    active=active,
+                    verified=True,
+                )
+        if status in accepted:
+            return {
+                "allowed": True,
+                "bot_id": str(BOT_PROFILE_ID),
+                "chat_id": int(chat_id),
+                "status": status_value(status),
+            }
+    return {"allowed": False}
+
+
+async def verify_platform_membership(
+    *,
+    user_id: int,
+    require_admin: bool = False,
+) -> dict[str, Any]:
+    if require_admin and is_sudoer(int(user_id)):
+        return {
+            "allowed": True,
+            "bot_id": str(BOT_PROFILE_ID),
+            "chat_id": None,
+            "status": "sudoer",
+        }
+    candidates = await list_membership_candidates(int(user_id))
+    if not candidates:
+        return {"allowed": False, "reason": "membership_evidence_required"}
+    internal_url = str(
+        getattr(wbb, "SUPERVISOR_INTERNAL_URL", "") or ""
+    ).rstrip("/")
+    internal_token = str(getattr(wbb, "INTERNAL_TOKEN", "") or "")
+    if internal_url and internal_token:
+        try:
+            async with ClientSession(timeout=ClientTimeout(total=20)) as session:
+                async with session.post(
+                    f"{internal_url}/api/internal/v1/directory/verify-platform",
+                    headers={"Authorization": f"Bearer {internal_token}"},
+                    json={
+                        "user_id": str(user_id),
+                        "require_admin": bool(require_admin),
+                    },
+                ) as response:
+                    if response.status != 200:
+                        return {"allowed": False, "reason": "verification_unavailable"}
+                    payload = await response.json()
+                    return payload.get("data") or {"allowed": False}
+        except (ClientError, TimeoutError, ValueError) as exc:
+            log.error(f"跨 Bot 成员身份校验失败:{exc}")
+            return {"allowed": False, "reason": "verification_unavailable"}
+    local_chat_ids = [
+        int(item["chat_id"])
+        for item in candidates
+        if str(item.get("bot_id")) == str(BOT_PROFILE_ID)
+    ]
+    if not local_chat_ids:
+        return {"allowed": False, "reason": "verification_unavailable"}
+    return await verify_local_membership(
+        user_id=int(user_id),
+        chat_ids=local_chat_ids,
+        require_admin=require_admin,
+    )
+
+
+async def require_platform_membership(
+    user_id: int,
+    *,
+    require_admin: bool = False,
+) -> dict[str, Any]:
+    result = await verify_platform_membership(
+        user_id=int(user_id),
+        require_admin=require_admin,
+    )
+    if result.get("allowed"):
+        return result
+    reason = result.get("reason")
+    if reason == "membership_evidence_required":
+        raise DirectoryServiceError(
+            "membership_verification_required",
+            "请先在任一受管群发送 /directory 完成群成员身份验证。",
+            status=403,
+        )
+    if reason == "verification_unavailable":
+        raise DirectoryServiceError(
+            "membership_verification_unavailable",
+            "暂时无法通过对应机器人核验群身份,请稍后重试。",
+            status=503,
+        )
+    raise DirectoryServiceError(
+        "managed_group_member_required",
+        "该功能仅对受管群的当前成员开放。",
+        status=403,
+    )
+
+
+async def update_user_location(
+    *,
+    user: Any,
+    longitude: float,
+    latitude: float,
+    source: str = "telegram_private",
+) -> tuple[dict[str, Any], bool | None]:
+    await require_platform_membership(int(user.id))
+    identity = user_identity(user)
+    await observe_directory_user(user)
+    previous = await get_directory_location(int(user.id))
+    consent = previous.get("geocode_consent") if previous else None
+    location = await save_directory_location(
+        user_id=int(user.id),
+        longitude=longitude,
+        latitude=latitude,
+        source=source,
+        actor_id=int(user.id),
+        actor_name=identity["display_name"],
+        geocode_consent=consent,
+        bot_id=str(BOT_PROFILE_ID),
+    )
+    return location, consent
+
+
+async def update_user_geocode_consent(
+    *,
+    user: Any,
+    consent: bool,
+) -> dict[str, Any]:
+    identity = user_identity(user)
+    return await set_geocode_consent(
+        user_id=int(user.id),
+        consent=consent,
+        source="telegram_private",
+        actor_id=int(user.id),
+        actor_name=identity["display_name"],
+    )
+
+
+async def clear_user_location(*, user: Any) -> bool:
+    identity = user_identity(user)
+    return await clear_directory_location(
+        user_id=int(user.id),
+        actor_id=int(user.id),
+        actor_name=identity["display_name"],
+        source="telegram_private",
+        reason="用户主动清除位置",
+    )
+
+
+async def apply_as_teacher(*, user: Any) -> dict[str, Any]:
+    await require_platform_membership(int(user.id))
+    await observe_directory_user(user)
+    try:
+        return await submit_teacher_application(
+            user_id=int(user.id),
+            source="telegram_private",
+            bot_id=str(BOT_PROFILE_ID),
+        )
+    except DirectoryDataError as exc:
+        raise DirectoryServiceError(exc.code, str(exc), status=409) from exc
+
+
+async def change_own_teacher_state(
+    *,
+    user: Any,
+    action: str,
+    source: str = "telegram_private",
+    idempotency_key: str | None = None,
+    chat_id: int | None = None,
+) -> tuple[dict[str, Any], bool]:
+    await require_platform_membership(int(user.id))
+    identity = user_identity(user)
+    await observe_directory_user(user)
+    try:
+        return await set_teacher_state(
+            user_id=int(user.id),
+            action=action,
+            actor_id=int(user.id),
+            actor_name=identity["display_name"],
+            source=source,
+            reason="老师自助操作",
+            idempotency_key=idempotency_key,
+            chat_id=chat_id,
+        )
+    except DirectoryDataError as exc:
+        raise DirectoryServiceError(exc.code, str(exc), status=409) from exc
+
+
+async def admin_decide_teacher(
+    *,
+    actor: Any,
+    user_id: int,
+    action: str,
+    reason: str = "",
+) -> dict[str, Any]:
+    authorization = await require_platform_membership(
+        int(actor.id),
+        require_admin=True,
+    )
+    identity = user_identity(actor)
+    try:
+        return await decide_teacher_application(
+            user_id=int(user_id),
+            action=action,
+            actor_id=int(actor.id),
+            actor_name=identity["display_name"],
+            source="telegram_private",
+            reason=reason,
+            authorization_chat_id=authorization.get("chat_id"),
+        )
+    except DirectoryDataError as exc:
+        raise DirectoryServiceError(exc.code, str(exc), status=409) from exc
+
+
+async def admin_change_teacher_state(
+    *,
+    actor: Any,
+    user_id: int,
+    action: str,
+    reason: str,
+) -> dict[str, Any]:
+    await require_platform_membership(int(actor.id), require_admin=True)
+    if not reason.strip():
+        raise DirectoryServiceError("reason_required", "必须填写操作原因。")
+    identity = user_identity(actor)
+    try:
+        profile, _ = await set_teacher_state(
+            user_id=int(user_id),
+            action=action,
+            actor_id=int(actor.id),
+            actor_name=identity["display_name"],
+            source="telegram_private",
+            reason=reason,
+        )
+        return profile
+    except DirectoryDataError as exc:
+        raise DirectoryServiceError(exc.code, str(exc), status=409) from exc
+
+
+async def directory_for_user(
+    *,
+    user: Any,
+    radius_km: int | None,
+    page: int = 1,
+    page_size: int = 10,
+) -> tuple[list[dict[str, Any]], int]:
+    await require_platform_membership(int(user.id))
+    await observe_directory_user(user)
+    location = await get_directory_location(int(user.id))
+    if not location:
+        raise DirectoryServiceError(
+            "location_required",
+            "请先点击“更新位置”发送定位,再查看老师。",
+            status=409,
+        )
+    try:
+        return await list_directory_teachers(
+            longitude=float(location["longitude"]),
+            latitude=float(location["latitude"]),
+            max_distance_meters=(
+                float(radius_km) * 1000 if radius_km is not None else None
+            ),
+            page=max(1, int(page)),
+            page_size=max(1, min(int(page_size), 20)),
+        )
+    except DirectoryDataError as exc:
+        raise DirectoryServiceError(exc.code, str(exc)) from exc
+
+
+def public_teacher(profile: dict[str, Any]) -> dict[str, Any]:
+    region = profile.get("region") or {}
+    return {
+        "user_id": int(profile["user_id"]),
+        "username": profile.get("username"),
+        "display_name": profile.get("display_name") or f"用户 {profile['user_id']}",
+        "online": teacher_is_online(profile),
+        "online_until": aware_utc(profile.get("online_until")),
+        "region": str(region.get("label") or "区域暂不可用"),
+        "distance_meters": float(profile.get("distance_meters") or 0),
+        "location_updated_at": aware_utc(profile.get("location_updated_at")),
+    }
+
+
+def profile_summary(profile: dict[str, Any], location: dict[str, Any] | None) -> dict[str, Any]:
+    return {
+        "user_id": int(profile["user_id"]),
+        "username": profile.get("username"),
+        "display_name": profile.get("display_name") or f"用户 {profile['user_id']}",
+        "application_status": profile.get("application_status", "none"),
+        "listed": bool(profile.get("listed")),
+        "online": teacher_is_online(profile),
+        "online_until": aware_utc(profile.get("online_until")),
+        "has_location": bool(location),
+        "region": (
+            str((location.get("region") or {}).get("label") or "区域暂不可用")
+            if location
+            else ""
+        ),
+        "geocode_consent": location.get("geocode_consent") if location else None,
+        "location_updated_at": aware_utc(location.get("updated_at")) if location else None,
+    }
+
+
+def format_distance(distance_meters: float) -> str:
+    if distance_meters < 1000:
+        return f"{max(0, round(distance_meters))} 米"
+    return f"{distance_meters / 1000:.1f} 公里"
+
+
+async def _presence_sweeper() -> None:
+    while True:
+        try:
+            await expire_teacher_presence()
+        except Exception as exc:
+            log.error(f"老师在线状态过期任务失败:{exc}")
+        await asyncio.sleep(60)
+
+
+def start_presence_sweeper() -> None:
+    global _presence_task
+    if _presence_task and not _presence_task.done():
+        return
+    try:
+        _presence_task = asyncio.create_task(
+            _presence_sweeper(),
+            name="teacher-presence-sweeper",
+        )
+    except RuntimeError:
+        _presence_task = None
+
+
+async def stop_presence_sweeper() -> None:
+    global _presence_task
+    if _presence_task:
+        _presence_task.cancel()
+        with suppress(asyncio.CancelledError):
+            await _presence_task
+    _presence_task = None

+ 161 - 0
wbb/services/directory_geocoding.py

@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import suppress
+from typing import Any
+
+from aiohttp import ClientError, ClientSession, ClientTimeout
+
+from wbb import log
+from wbb.utils.dbdirectory import (
+    claim_geocode_job,
+    complete_geocode_job,
+    fail_geocode_job,
+    get_directory_settings,
+)
+
+NOMINATIM_ATTRIBUTION = "© OpenStreetMap contributors"
+NOMINATIM_ATTRIBUTION_URL = "https://www.openstreetmap.org/copyright"
+
+_worker_task: asyncio.Task[None] | None = None
+_stop_event: asyncio.Event | None = None
+
+
+def parse_nominatim_region(payload: dict[str, Any]) -> dict[str, str]:
+    address = payload.get("address") if isinstance(payload.get("address"), dict) else {}
+    province = str(address.get("state") or address.get("province") or "").strip()
+    city = str(
+        address.get("city")
+        or address.get("municipality")
+        or address.get("town")
+        or address.get("county")
+        or ""
+    ).strip()
+    district = str(
+        address.get("city_district")
+        or address.get("district")
+        or address.get("borough")
+        or address.get("suburb")
+        or address.get("county")
+        or ""
+    ).strip()
+    values = list(dict.fromkeys(value for value in (province, city, district) if value))
+    return {
+        "province": province,
+        "city": city,
+        "district": district,
+        "label": " ".join(values) if values else "区域暂不可用",
+        "provider": "nominatim",
+    }
+
+
+async def reverse_geocode(
+    *,
+    endpoint: str,
+    contact: str,
+    user_agent: str,
+    longitude: float,
+    latitude: float,
+) -> dict[str, str]:
+    headers = {
+        "User-Agent": f"{user_agent} ({contact})",
+        "Accept": "application/json",
+        "Accept-Language": "zh-CN,zh;q=0.9",
+    }
+    params = {
+        "format": "jsonv2",
+        "lat": repr(float(latitude)),
+        "lon": repr(float(longitude)),
+        "zoom": "12",
+        "addressdetails": "1",
+        "accept-language": "zh-CN",
+    }
+    timeout = ClientTimeout(total=12)
+    async with ClientSession(timeout=timeout, headers=headers) as session:
+        async with session.get(endpoint, params=params) as response:
+            if response.status == 429:
+                retry_after = int(response.headers.get("Retry-After") or 60)
+                raise NominatimRateLimited(retry_after)
+            if response.status >= 400:
+                text = await response.text()
+                raise RuntimeError(
+                    f"Nominatim 返回 HTTP {response.status}: {text[:200]}"
+                )
+            payload = await response.json()
+    if not isinstance(payload, dict):
+        raise RuntimeError("Nominatim 返回了无效数据。")
+    return parse_nominatim_region(payload)
+
+
+class NominatimRateLimited(RuntimeError):
+    def __init__(self, retry_after_seconds: int):
+        super().__init__("Nominatim 请求已被限流。")
+        self.retry_after_seconds = max(1, int(retry_after_seconds))
+
+
+async def _geocode_worker(stop_event: asyncio.Event) -> None:
+    last_request_at = 0.0
+    while not stop_event.is_set():
+        settings = await get_directory_settings()
+        enabled = bool(settings.get("nominatim_enabled"))
+        contact = str(settings.get("nominatim_contact") or "").strip()
+        if not enabled or not contact:
+            with suppress(TimeoutError):
+                await asyncio.wait_for(stop_event.wait(), timeout=2)
+            continue
+        job = await claim_geocode_job()
+        if not job:
+            with suppress(TimeoutError):
+                await asyncio.wait_for(stop_event.wait(), timeout=1)
+            continue
+        loop = asyncio.get_running_loop()
+        wait_seconds = 1.1 - (loop.time() - last_request_at)
+        if wait_seconds > 0:
+            with suppress(TimeoutError):
+                await asyncio.wait_for(stop_event.wait(), timeout=wait_seconds)
+            if stop_event.is_set():
+                return
+        try:
+            last_request_at = loop.time()
+            region = await reverse_geocode(
+                endpoint=str(settings["nominatim_endpoint"]),
+                contact=contact,
+                user_agent=str(settings["nominatim_user_agent"]),
+                longitude=float(job["longitude"]),
+                latitude=float(job["latitude"]),
+            )
+            await complete_geocode_job(job=job, region=region)
+        except NominatimRateLimited as exc:
+            await fail_geocode_job(
+                job=job,
+                error=str(exc),
+                retry_after_seconds=exc.retry_after_seconds,
+            )
+        except (ClientError, TimeoutError, ValueError, RuntimeError) as exc:
+            log.error(f"Nominatim 区域解析失败:{exc}")
+            await fail_geocode_job(job=job, error=str(exc), retry_after_seconds=60)
+        except Exception as exc:
+            log.error(f"Nominatim 区域解析出现未预期错误:{exc}")
+            await fail_geocode_job(job=job, error="区域解析服务异常。")
+
+
+async def start_directory_geocoder() -> None:
+    global _worker_task, _stop_event
+    if _worker_task and not _worker_task.done():
+        return
+    _stop_event = asyncio.Event()
+    _worker_task = asyncio.create_task(
+        _geocode_worker(_stop_event),
+        name="directory-geocoder",
+    )
+
+
+async def stop_directory_geocoder() -> None:
+    global _worker_task, _stop_event
+    if _stop_event:
+        _stop_event.set()
+    if _worker_task:
+        with suppress(asyncio.CancelledError):
+            await asyncio.wait_for(_worker_task, timeout=3)
+    _worker_task = None
+    _stop_event = None

+ 1227 - 0
wbb/utils/dbdirectory.py

@@ -0,0 +1,1227 @@
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import math
+import re
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from uuid import uuid4
+
+from pymongo import ASCENDING, DESCENDING, GEOSPHERE, ReturnDocument
+from pymongo.errors import DuplicateKeyError, OperationFailure
+
+from wbb import BOT_PROFILE_ID, control_db
+
+profilesdb = control_db.directory_profiles
+locationsdb = control_db.directory_locations
+membershipsdb = control_db.directory_memberships
+eventsdb = control_db.directory_events
+settingsdb = control_db.directory_settings
+geocode_cachedb = control_db.directory_geocode_cache
+geocode_jobsdb = control_db.directory_geocode_jobs
+
+APPLICATION_NONE = "none"
+APPLICATION_PENDING = "pending"
+APPLICATION_APPROVED = "approved"
+APPLICATION_REJECTED = "rejected"
+APPLICATION_REVOKED = "revoked"
+APPLICATION_STATUSES = {
+    APPLICATION_NONE,
+    APPLICATION_PENDING,
+    APPLICATION_APPROVED,
+    APPLICATION_REJECTED,
+    APPLICATION_REVOKED,
+}
+
+PRESENCE_ONLINE = "online"
+PRESENCE_OFFLINE = "offline"
+
+DEFAULT_DIRECTORY_SETTINGS: dict[str, Any] = {
+    "nominatim_enabled": True,
+    "nominatim_endpoint": "https://nominatim.openstreetmap.org/reverse",
+    "nominatim_contact": "",
+    "nominatim_user_agent": "TelegramBotAdmin/1.0",
+    "online_duration_hours": 24,
+    "nearby_radius_km": 20,
+    "nearby_radius_options_km": [5, 10, 20, 50],
+}
+
+_index_lock = asyncio.Lock()
+_indexes_ready = False
+
+
+class DirectoryDataError(ValueError):
+    def __init__(self, code: str, message: str):
+        super().__init__(message)
+        self.code = code
+
+
+def utc_now() -> datetime:
+    return datetime.now(UTC)
+
+
+def aware_utc(value: datetime | None) -> datetime | None:
+    if value is None:
+        return None
+    if value.tzinfo is None:
+        return value.replace(tzinfo=UTC)
+    return value.astimezone(UTC)
+
+
+def display_name(
+    first_name: str | None,
+    last_name: str | None,
+    username: str | None = None,
+    user_id: int | None = None,
+) -> str:
+    name = " ".join(
+        value.strip()
+        for value in (first_name, last_name)
+        if value and value.strip()
+    )
+    if name:
+        return name
+    if username:
+        return f"@{username}"
+    return f"用户 {user_id}" if user_id is not None else "未知用户"
+
+
+async def ensure_directory_indexes() -> None:
+    global _indexes_ready
+    if _indexes_ready:
+        return
+    async with _index_lock:
+        if _indexes_ready:
+            return
+        await profilesdb.create_index([("user_id", ASCENDING)], unique=True)
+        await profilesdb.create_index(
+            [
+                ("application_status", ASCENDING),
+                ("listed", ASCENDING),
+                ("updated_at", DESCENDING),
+            ]
+        )
+        await profilesdb.create_index(
+            [("presence_status", ASCENDING), ("online_until", ASCENDING)]
+        )
+        await locationsdb.create_index([("user_id", ASCENDING)], unique=True)
+        await locationsdb.create_index([("point", GEOSPHERE)])
+        await locationsdb.create_index([("updated_at", DESCENDING)])
+        await membershipsdb.create_index(
+            [
+                ("bot_id", ASCENDING),
+                ("chat_id", ASCENDING),
+                ("user_id", ASCENDING),
+            ],
+            unique=True,
+        )
+        await membershipsdb.create_index(
+            [("user_id", ASCENDING), ("active", ASCENDING), ("verified_at", DESCENDING)]
+        )
+        await eventsdb.create_index([("event_id", ASCENDING)], unique=True)
+        await eventsdb.create_index(
+            [("idempotency_key", ASCENDING)],
+            unique=True,
+            sparse=True,
+        )
+        await eventsdb.create_index([("created_at", DESCENDING)])
+        await eventsdb.create_index([("user_id", ASCENDING), ("created_at", DESCENDING)])
+        await settingsdb.create_index([("settings_id", ASCENDING)], unique=True)
+        await geocode_cachedb.create_index([("coordinate_key", ASCENDING)], unique=True)
+        await geocode_jobsdb.create_index([("user_id", ASCENDING)], unique=True)
+        await geocode_jobsdb.create_index(
+            [("status", ASCENDING), ("next_attempt_at", ASCENDING)]
+        )
+        _indexes_ready = True
+
+
+async def get_directory_settings() -> dict[str, Any]:
+    await ensure_directory_indexes()
+    stored = await settingsdb.find_one({"settings_id": "global"}) or {}
+    return {
+        **DEFAULT_DIRECTORY_SETTINGS,
+        **{
+            key: value
+            for key, value in stored.items()
+            if key not in {"_id", "settings_id"}
+        },
+    }
+
+
+async def set_directory_settings(values: dict[str, Any]) -> dict[str, Any]:
+    await ensure_directory_indexes()
+    current = await get_directory_settings()
+    target = {**current, **values}
+    endpoint = str(target.get("nominatim_endpoint") or "").strip()
+    if not endpoint.startswith(("https://", "http://127.0.0.1", "http://localhost")):
+        raise DirectoryDataError(
+            "invalid_nominatim_endpoint",
+            "Nominatim 地址必须使用 HTTPS;仅本机自建服务可以使用 HTTP。",
+        )
+    try:
+        radius_options = sorted(
+            {
+                int(value)
+                for value in target.get("nearby_radius_options_km", [])
+                if int(value) > 0
+            }
+        )
+        default_radius = int(target.get("nearby_radius_km") or 0)
+    except (TypeError, ValueError) as exc:
+        raise DirectoryDataError(
+            "invalid_radius_options", "附近范围必须是正整数。"
+        ) from exc
+    if not radius_options:
+        raise DirectoryDataError("invalid_radius_options", "附近范围选项不能为空。")
+    if default_radius not in radius_options:
+        raise DirectoryDataError(
+            "invalid_default_radius", "默认附近范围必须属于可选范围。"
+        )
+    contact = str(target.get("nominatim_contact") or "").strip()
+    user_agent = str(
+        target.get("nominatim_user_agent") or "TelegramBotAdmin/1.0"
+    ).strip()
+    if "\n" in contact or "\r" in contact or "\n" in user_agent or "\r" in user_agent:
+        raise DirectoryDataError(
+            "invalid_nominatim_identity",
+            "Nominatim 联系信息和 User-Agent 不能包含换行符。",
+        )
+    if contact and "@" not in contact and not contact.startswith(("https://", "http://")):
+        raise DirectoryDataError(
+            "invalid_nominatim_contact",
+            "Nominatim 联系信息必须是邮箱或 URL。",
+        )
+    target.update(
+        {
+            "nominatim_enabled": bool(target.get("nominatim_enabled")),
+            "nominatim_endpoint": endpoint,
+            "nominatim_contact": contact,
+            "nominatim_user_agent": user_agent,
+            "online_duration_hours": 24,
+            "nearby_radius_km": default_radius,
+            "nearby_radius_options_km": radius_options,
+            "updated_at": utc_now(),
+        }
+    )
+    await settingsdb.update_one(
+        {"settings_id": "global"},
+        {"$set": target, "$setOnInsert": {"created_at": utc_now()}},
+        upsert=True,
+    )
+    return await get_directory_settings()
+
+
+async def record_directory_event(
+    *,
+    event_type: str,
+    user_id: int,
+    actor_id: int | str | None,
+    actor_name: str = "",
+    source: str,
+    reason: str = "",
+    metadata: dict[str, Any] | None = None,
+    idempotency_key: str | None = None,
+    bot_id: str | None = None,
+    chat_id: int | None = None,
+) -> dict[str, Any] | None:
+    await ensure_directory_indexes()
+    document = {
+        "event_id": uuid4().hex,
+        "event_type": event_type,
+        "user_id": int(user_id),
+        "actor_id": actor_id,
+        "actor_name": actor_name,
+        "source": source,
+        "reason": reason,
+        "metadata": metadata or {},
+        "bot_id": bot_id or str(BOT_PROFILE_ID),
+        "chat_id": int(chat_id) if chat_id is not None else None,
+        "created_at": utc_now(),
+    }
+    if idempotency_key:
+        document["idempotency_key"] = idempotency_key
+    try:
+        await eventsdb.insert_one(document)
+    except DuplicateKeyError:
+        return None
+    return document
+
+
+async def upsert_directory_identity(
+    *,
+    user_id: int,
+    username: str | None,
+    first_name: str | None,
+    last_name: str | None,
+    source: str = "telegram",
+) -> dict[str, Any]:
+    await ensure_directory_indexes()
+    normalized_username = str(username or "").lstrip("@").strip() or None
+    now = utc_now()
+    previous = await profilesdb.find_one({"user_id": int(user_id)})
+    identity = {
+        "username": normalized_username,
+        "first_name": str(first_name or "").strip() or None,
+        "last_name": str(last_name or "").strip() or None,
+        "display_name": display_name(
+            first_name,
+            last_name,
+            normalized_username,
+            int(user_id),
+        ),
+    }
+    updates: dict[str, Any] = {**identity, "updated_at": now}
+    username_removed = bool(previous and previous.get("username") and not normalized_username)
+    if username_removed and previous.get("application_status") == APPLICATION_APPROVED:
+        updates.update(
+            {
+                "listed": False,
+                "presence_status": PRESENCE_OFFLINE,
+                "online_until": None,
+            }
+        )
+    await profilesdb.update_one(
+        {"user_id": int(user_id)},
+        {
+            "$set": updates,
+            "$setOnInsert": {
+                "application_status": APPLICATION_NONE,
+                "listed": False,
+                "presence_status": PRESENCE_OFFLINE,
+                "created_at": now,
+            },
+        },
+        upsert=True,
+    )
+    if username_removed:
+        await record_directory_event(
+            event_type="teacher_username_removed",
+            user_id=int(user_id),
+            actor_id=int(user_id),
+            actor_name=identity["display_name"],
+            source=source,
+            reason="老师移除了 Telegram 用户名,系统自动下榜并下线。",
+        )
+    return await get_directory_profile(int(user_id)) or {}
+
+
+async def get_directory_profile(user_id: int) -> dict[str, Any] | None:
+    await ensure_directory_indexes()
+    return await profilesdb.find_one({"user_id": int(user_id)})
+
+
+async def get_directory_location(user_id: int) -> dict[str, Any] | None:
+    await ensure_directory_indexes()
+    return await locationsdb.find_one({"user_id": int(user_id)})
+
+
+def validate_coordinates(longitude: float, latitude: float) -> tuple[float, float]:
+    try:
+        lon = float(longitude)
+        lat = float(latitude)
+    except (TypeError, ValueError) as exc:
+        raise DirectoryDataError("invalid_coordinates", "经纬度必须是数字。") from exc
+    if not math.isfinite(lon) or not -180 <= lon <= 180:
+        raise DirectoryDataError("invalid_longitude", "经度必须在 -180 到 180 之间。")
+    if not math.isfinite(lat) or not -90 <= lat <= 90:
+        raise DirectoryDataError("invalid_latitude", "纬度必须在 -90 到 90 之间。")
+    return lon, lat
+
+
+def coordinate_key(longitude: float, latitude: float) -> str:
+    raw = json.dumps(
+        [float(longitude), float(latitude)],
+        ensure_ascii=True,
+        separators=(",", ":"),
+    )
+    return hashlib.sha256(raw.encode("ascii")).hexdigest()
+
+
+async def save_directory_location(
+    *,
+    user_id: int,
+    longitude: float,
+    latitude: float,
+    source: str,
+    actor_id: int | str,
+    actor_name: str,
+    geocode_consent: bool | None = None,
+    bot_id: str | None = None,
+) -> dict[str, Any]:
+    await ensure_directory_indexes()
+    lon, lat = validate_coordinates(longitude, latitude)
+    previous = await get_directory_location(int(user_id))
+    consent = (
+        geocode_consent
+        if geocode_consent is not None
+        else previous.get("geocode_consent") if previous else None
+    )
+    key = coordinate_key(lon, lat)
+    now = utc_now()
+    status = "pending" if consent is True else "disabled" if consent is False else "consent_required"
+    await locationsdb.update_one(
+        {"user_id": int(user_id)},
+        {
+            "$set": {
+                "point": {"type": "Point", "coordinates": [lon, lat]},
+                "longitude": lon,
+                "latitude": lat,
+                "coordinate_key": key,
+                "geocode_consent": consent,
+                "geocode_status": status,
+                "region": None,
+                "source": source,
+                "source_bot_id": bot_id or str(BOT_PROFILE_ID),
+                "updated_at": now,
+            },
+            "$setOnInsert": {"created_at": now},
+        },
+        upsert=True,
+    )
+    await profilesdb.update_one(
+        {"user_id": int(user_id)},
+        {
+            "$set": {"location_updated_at": now, "updated_at": now},
+            "$setOnInsert": {
+                "application_status": APPLICATION_NONE,
+                "listed": False,
+                "presence_status": PRESENCE_OFFLINE,
+                "created_at": now,
+            },
+        },
+        upsert=True,
+    )
+    await record_directory_event(
+        event_type="location_updated",
+        user_id=int(user_id),
+        actor_id=actor_id,
+        actor_name=actor_name,
+        source=source,
+        reason="更新位置",
+        metadata={"geocode_consent": consent},
+        bot_id=bot_id,
+    )
+    if consent is True:
+        await enqueue_geocode_job(int(user_id))
+    return await get_directory_location(int(user_id)) or {}
+
+
+async def set_geocode_consent(
+    *,
+    user_id: int,
+    consent: bool,
+    source: str,
+    actor_id: int | str,
+    actor_name: str,
+) -> dict[str, Any]:
+    location = await get_directory_location(int(user_id))
+    if not location:
+        raise DirectoryDataError("location_required", "请先更新位置。")
+    updates: dict[str, Any] = {
+        "geocode_consent": bool(consent),
+        "geocode_status": "pending" if consent else "disabled",
+        "updated_at": utc_now(),
+    }
+    if not consent:
+        updates["region"] = None
+        await geocode_jobsdb.delete_one({"user_id": int(user_id)})
+    await locationsdb.update_one({"user_id": int(user_id)}, {"$set": updates})
+    await record_directory_event(
+        event_type="geocode_consent_updated",
+        user_id=int(user_id),
+        actor_id=actor_id,
+        actor_name=actor_name,
+        source=source,
+        reason="同意区域解析" if consent else "关闭区域解析",
+        metadata={"geocode_consent": bool(consent)},
+    )
+    if consent:
+        await enqueue_geocode_job(int(user_id))
+    return await get_directory_location(int(user_id)) or {}
+
+
+async def clear_directory_location(
+    *,
+    user_id: int,
+    actor_id: int | str,
+    actor_name: str,
+    source: str,
+    reason: str,
+) -> bool:
+    await ensure_directory_indexes()
+    result = await locationsdb.delete_one({"user_id": int(user_id)})
+    await geocode_jobsdb.delete_one({"user_id": int(user_id)})
+    await profilesdb.update_one(
+        {"user_id": int(user_id)},
+        {
+            "$set": {
+                "listed": False,
+                "presence_status": PRESENCE_OFFLINE,
+                "online_until": None,
+                "location_updated_at": None,
+                "updated_at": utc_now(),
+            }
+        },
+    )
+    if result.deleted_count:
+        await record_directory_event(
+            event_type="location_cleared",
+            user_id=int(user_id),
+            actor_id=actor_id,
+            actor_name=actor_name,
+            source=source,
+            reason=reason,
+        )
+    return bool(result.deleted_count)
+
+
+async def submit_teacher_application(
+    *,
+    user_id: int,
+    source: str,
+    bot_id: str,
+) -> dict[str, Any]:
+    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:
+        raise DirectoryDataError(
+            "teacher_revoked", "老师资格已被撤销,只能由管理员重新批准。"
+        )
+    if profile.get("application_status") == APPLICATION_PENDING:
+        return profile
+    now = utc_now()
+    await profilesdb.update_one(
+        {"user_id": int(user_id)},
+        {
+            "$set": {
+                "application_status": APPLICATION_PENDING,
+                "application_reason": "",
+                "application_source_bot_id": str(bot_id),
+                "applied_at": now,
+                "decided_at": None,
+                "decided_by": None,
+                "updated_at": now,
+            }
+        },
+    )
+    await record_directory_event(
+        event_type="teacher_application_submitted",
+        user_id=int(user_id),
+        actor_id=int(user_id),
+        actor_name=str(profile.get("display_name") or user_id),
+        source=source,
+        reason="申请成为老师",
+        bot_id=bot_id,
+    )
+    return await get_directory_profile(int(user_id)) or {}
+
+
+async def decide_teacher_application(
+    *,
+    user_id: int,
+    action: str,
+    actor_id: int | str,
+    actor_name: str,
+    source: str,
+    reason: str = "",
+    authorization_chat_id: int | None = None,
+) -> dict[str, Any]:
+    await ensure_directory_indexes()
+    profile = await get_directory_profile(int(user_id))
+    if not profile:
+        raise DirectoryDataError("profile_not_found", "未找到该成员资料。")
+    action_status = {
+        "approve": APPLICATION_APPROVED,
+        "reject": APPLICATION_REJECTED,
+        "revoke": APPLICATION_REVOKED,
+    }
+    if action not in action_status:
+        raise DirectoryDataError("invalid_decision", "不支持的审批操作。")
+    if action in {"reject", "revoke"} and not reason.strip():
+        raise DirectoryDataError("reason_required", "拒绝或撤销时必须填写原因。")
+    if action == "approve":
+        if not profile.get("username"):
+            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],
+        "application_reason": reason.strip(),
+        "decided_at": now,
+        "decided_by": actor_id,
+        "decided_by_name": actor_name,
+        "authorization_chat_id": authorization_chat_id,
+        "updated_at": now,
+    }
+    if action != "approve":
+        updates.update(
+            {
+                "listed": False,
+                "presence_status": PRESENCE_OFFLINE,
+                "online_until": None,
+            }
+        )
+    await profilesdb.update_one({"user_id": int(user_id)}, {"$set": updates})
+    await record_directory_event(
+        event_type=f"teacher_application_{action}",
+        user_id=int(user_id),
+        actor_id=actor_id,
+        actor_name=actor_name,
+        source=source,
+        reason=reason,
+        metadata={"authorization_chat_id": authorization_chat_id},
+        chat_id=authorization_chat_id,
+    )
+    return await get_directory_profile(int(user_id)) or {}
+
+
+async def set_teacher_state(
+    *,
+    user_id: int,
+    action: str,
+    actor_id: int | str,
+    actor_name: str,
+    source: str,
+    reason: str = "",
+    idempotency_key: str | None = None,
+    chat_id: int | None = None,
+) -> tuple[dict[str, Any], bool]:
+    await ensure_directory_indexes()
+    profile = await get_directory_profile(int(user_id))
+    if not profile or profile.get("application_status") != APPLICATION_APPROVED:
+        raise DirectoryDataError("teacher_required", "该成员尚未取得老师资格。")
+    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"}:
+        raise DirectoryDataError("invalid_teacher_action", "不支持的老师状态操作。")
+    event = await record_directory_event(
+        event_type=f"teacher_{action}",
+        user_id=int(user_id),
+        actor_id=actor_id,
+        actor_name=actor_name,
+        source=source,
+        reason=reason or f"老师{action}",
+        idempotency_key=idempotency_key,
+        chat_id=chat_id,
+    )
+    applied = event is not None
+    now = utc_now()
+    updates: dict[str, Any] = {"updated_at": now}
+    if action == "list":
+        updates["listed"] = True
+        updates["listed_at"] = now
+    elif action == "unlist":
+        updates.update(
+            {
+                "listed": False,
+                "presence_status": PRESENCE_OFFLINE,
+                "online_until": None,
+            }
+        )
+    elif action == "online":
+        updates.update(
+            {
+                "presence_status": PRESENCE_ONLINE,
+                "online_until": now + timedelta(hours=24),
+                "last_online_at": now,
+            }
+        )
+    else:
+        updates.update(
+            {"presence_status": PRESENCE_OFFLINE, "online_until": None}
+        )
+    await profilesdb.update_one({"user_id": int(user_id)}, {"$set": updates})
+    return await get_directory_profile(int(user_id)) or {}, applied
+
+
+async def expire_teacher_presence() -> int:
+    await ensure_directory_indexes()
+    now = utc_now()
+    expired = 0
+    while True:
+        item = await profilesdb.find_one_and_update(
+            {
+                "presence_status": PRESENCE_ONLINE,
+                "online_until": {"$lte": now},
+            },
+            {
+                "$set": {
+                    "presence_status": PRESENCE_OFFLINE,
+                    "online_until": None,
+                    "updated_at": now,
+                }
+            },
+            sort=[("online_until", ASCENDING)],
+            return_document=ReturnDocument.BEFORE,
+        )
+        if not item:
+            break
+        expired += 1
+        previous_until = aware_utc(item.get("online_until"))
+        await record_directory_event(
+            event_type="teacher_auto_offline",
+            user_id=int(item["user_id"]),
+            actor_id="system",
+            actor_name="系统",
+            source="scheduler",
+            reason="上线状态已超过 24 小时。",
+            idempotency_key=(
+                f"teacher-auto-offline:{item['user_id']}:"
+                f"{previous_until.isoformat() if previous_until else 'unknown'}"
+            ),
+        )
+    return expired
+
+
+def teacher_is_online(profile: dict[str, Any], *, now: datetime | None = None) -> bool:
+    current = now or utc_now()
+    until = aware_utc(profile.get("online_until"))
+    return bool(
+        profile.get("presence_status") == PRESENCE_ONLINE
+        and until
+        and until > current
+    )
+
+
+def _haversine_meters(
+    longitude_a: float,
+    latitude_a: float,
+    longitude_b: float,
+    latitude_b: float,
+) -> float:
+    radius = 6_371_008.8
+    lon_a, lat_a, lon_b, lat_b = map(
+        math.radians,
+        (longitude_a, latitude_a, longitude_b, latitude_b),
+    )
+    delta_lon = lon_b - lon_a
+    delta_lat = lat_b - lat_a
+    value = (
+        math.sin(delta_lat / 2) ** 2
+        + math.cos(lat_a) * math.cos(lat_b) * math.sin(delta_lon / 2) ** 2
+    )
+    return 2 * radius * math.asin(math.sqrt(value))
+
+
+async def _list_teachers_fallback(
+    *,
+    longitude: float,
+    latitude: float,
+    max_distance_meters: float | None,
+    query: str,
+    page: int,
+    page_size: int,
+) -> tuple[list[dict[str, Any]], int]:
+    profile_filter: dict[str, Any] = {
+        "application_status": APPLICATION_APPROVED,
+        "listed": True,
+        "username": {"$nin": [None, ""]},
+    }
+    if query:
+        pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
+        profile_filter["$or"] = [
+            {"username": pattern},
+            {"display_name": pattern},
+            {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
+        ]
+    profiles = {
+        int(item["user_id"]): item
+        async for item in profilesdb.find(profile_filter)
+    }
+    values: list[dict[str, Any]] = []
+    if profiles:
+        async for location in locationsdb.find(
+            {"user_id": {"$in": list(profiles)}}
+        ):
+            point = location.get("point", {}).get("coordinates") or []
+            if len(point) != 2:
+                continue
+            distance = _haversine_meters(
+                longitude,
+                latitude,
+                float(point[0]),
+                float(point[1]),
+            )
+            if max_distance_meters is not None and distance > max_distance_meters:
+                continue
+            values.append(
+                {
+                    **profiles[int(location["user_id"])],
+                    "distance_meters": distance,
+                    "region": location.get("region"),
+                    "location_updated_at": location.get("updated_at"),
+                }
+            )
+    values.sort(key=lambda item: (item["distance_meters"], int(item["user_id"])))
+    total = len(values)
+    start = (page - 1) * page_size
+    return values[start : start + page_size], total
+
+
+async def list_directory_teachers(
+    *,
+    longitude: float,
+    latitude: float,
+    max_distance_meters: float | None = None,
+    query: str = "",
+    page: int = 1,
+    page_size: int = 10,
+) -> tuple[list[dict[str, Any]], int]:
+    await ensure_directory_indexes()
+    lon, lat = validate_coordinates(longitude, latitude)
+    profile_match: dict[str, Any] = {
+        "profile.application_status": APPLICATION_APPROVED,
+        "profile.listed": True,
+        "profile.username": {"$nin": [None, ""]},
+    }
+    if query:
+        pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
+        profile_match["$or"] = [
+            {"profile.username": pattern},
+            {"profile.display_name": pattern},
+            {"profile.user_id": int(query)} if query.isdigit() else {"profile.user_id": -1},
+        ]
+    geo_near: dict[str, Any] = {
+        "near": {"type": "Point", "coordinates": [lon, lat]},
+        "distanceField": "distance_meters",
+        "spherical": True,
+        "key": "point",
+    }
+    if max_distance_meters is not None:
+        geo_near["maxDistance"] = float(max_distance_meters)
+    pipeline = [
+        {"$geoNear": geo_near},
+        {
+            "$lookup": {
+                "from": profilesdb.name,
+                "localField": "user_id",
+                "foreignField": "user_id",
+                "as": "profile",
+            }
+        },
+        {"$unwind": "$profile"},
+        {"$match": profile_match},
+        {"$sort": {"distance_meters": 1, "user_id": 1}},
+        {
+            "$facet": {
+                "items": [
+                    {"$skip": (max(1, page) - 1) * max(1, page_size)},
+                    {"$limit": max(1, page_size)},
+                ],
+                "count": [{"$count": "total"}],
+            }
+        },
+    ]
+    try:
+        results = await locationsdb.aggregate(pipeline).to_list(length=1)
+        result = results[0] if results else {"items": [], "count": []}
+        items = [
+            {
+                **item["profile"],
+                "distance_meters": item["distance_meters"],
+                "region": item.get("region"),
+                "location_updated_at": item.get("updated_at"),
+            }
+            for item in result.get("items", [])
+        ]
+        count = result.get("count") or []
+        return items, int(count[0]["total"]) if count else 0
+    except (NotImplementedError, OperationFailure):
+        return await _list_teachers_fallback(
+            longitude=lon,
+            latitude=lat,
+            max_distance_meters=max_distance_meters,
+            query=query,
+            page=page,
+            page_size=page_size,
+        )
+
+
+async def list_teacher_applications(
+    *,
+    status: str = "",
+    query: str = "",
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    await ensure_directory_indexes()
+    filters: dict[str, Any] = {}
+    if status:
+        if status not in APPLICATION_STATUSES:
+            raise DirectoryDataError("invalid_application_status", "申请状态无效。")
+        filters["application_status"] = status
+    else:
+        filters["application_status"] = {"$ne": APPLICATION_NONE}
+    if query:
+        pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
+        filters["$or"] = [
+            {"username": pattern},
+            {"display_name": pattern},
+            {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
+        ]
+    total = await profilesdb.count_documents(filters)
+    items = await (
+        profilesdb.find(filters)
+        .sort([("applied_at", DESCENDING), ("updated_at", DESCENDING)])
+        .skip((page - 1) * page_size)
+        .limit(page_size)
+        .to_list(length=page_size)
+    )
+    location_map = {
+        int(item["user_id"]): item
+        async for item in locationsdb.find(
+            {"user_id": {"$in": [int(item["user_id"]) for item in items]}}
+        )
+    }
+    for item in items:
+        location = location_map.get(int(item["user_id"]))
+        item["location"] = (
+            {
+                "region": location.get("region"),
+                "geocode_status": location.get("geocode_status"),
+                "updated_at": location.get("updated_at"),
+            }
+            if location
+            else None
+        )
+        item["online"] = teacher_is_online(item)
+    return items, total
+
+
+async def list_directory_profiles(
+    *,
+    query: str = "",
+    application_status: str = "",
+    listed: bool | None = None,
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    filters: dict[str, Any] = {}
+    if application_status:
+        filters["application_status"] = application_status
+    if listed is not None:
+        filters["listed"] = listed
+    if query:
+        pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
+        filters["$or"] = [
+            {"username": pattern},
+            {"display_name": pattern},
+            {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
+        ]
+    total = await profilesdb.count_documents(filters)
+    items = await (
+        profilesdb.find(filters)
+        .sort([("updated_at", DESCENDING)])
+        .skip((page - 1) * page_size)
+        .limit(page_size)
+        .to_list(length=page_size)
+    )
+    locations = {
+        int(item["user_id"]): item
+        async for item in locationsdb.find(
+            {"user_id": {"$in": [int(item["user_id"]) for item in items]}}
+        )
+    }
+    for item in items:
+        location = locations.get(int(item["user_id"]))
+        item["location"] = (
+            {
+                "region": location.get("region"),
+                "geocode_status": location.get("geocode_status"),
+                "updated_at": location.get("updated_at"),
+            }
+            if location
+            else None
+        )
+        item["online"] = teacher_is_online(item)
+    return items, total
+
+
+async def list_directory_locations(
+    *,
+    query: str = "",
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    await ensure_directory_indexes()
+    filters: dict[str, Any] = {}
+    if query:
+        pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
+        profile_filters = {
+            "$or": [
+                {"username": pattern},
+                {"display_name": pattern},
+                {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
+            ]
+        }
+        user_ids = [
+            int(item["user_id"])
+            async for item in profilesdb.find(profile_filters, {"user_id": 1})
+        ]
+        filters["user_id"] = {"$in": user_ids}
+    total = await locationsdb.count_documents(filters)
+    items = await (
+        locationsdb.find(filters)
+        .sort("updated_at", DESCENDING)
+        .skip((page - 1) * page_size)
+        .limit(page_size)
+        .to_list(length=page_size)
+    )
+    profiles = {
+        int(item["user_id"]): item
+        async for item in profilesdb.find(
+            {"user_id": {"$in": [int(item["user_id"]) for item in items]}}
+        )
+    }
+    for item in items:
+        item["profile"] = profiles.get(int(item["user_id"]), {})
+    return items, total
+
+
+async def list_directory_events(
+    *,
+    query: str = "",
+    event_type: str = "",
+    page: int = 1,
+    page_size: int = 20,
+) -> tuple[list[dict[str, Any]], int]:
+    filters: dict[str, Any] = {}
+    if event_type:
+        filters["event_type"] = event_type
+    if query:
+        pattern = re.compile(re.escape(query), re.IGNORECASE)
+        filters["$or"] = [
+            {"actor_name": pattern},
+            {"reason": pattern},
+            {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
+        ]
+    total = await eventsdb.count_documents(filters)
+    items = await (
+        eventsdb.find(filters)
+        .sort("created_at", DESCENDING)
+        .skip((page - 1) * page_size)
+        .limit(page_size)
+        .to_list(length=page_size)
+    )
+    return items, total
+
+
+async def upsert_directory_membership(
+    *,
+    bot_id: str,
+    chat_id: int,
+    user_id: int,
+    status: str,
+    active: bool,
+    chat_title: str = "",
+    username: str | None = None,
+    display_name_value: str = "",
+    verified: bool = False,
+) -> dict[str, Any]:
+    await ensure_directory_indexes()
+    now = utc_now()
+    values: dict[str, Any] = {
+        "status": status,
+        "active": bool(active),
+        "username": username,
+        "display_name": display_name_value,
+        "observed_at": now,
+        "updated_at": now,
+    }
+    if chat_title:
+        values["chat_title"] = chat_title
+    if verified:
+        values["verified_at"] = now
+    await membershipsdb.update_one(
+        {
+            "bot_id": str(bot_id),
+            "chat_id": int(chat_id),
+            "user_id": int(user_id),
+        },
+        {"$set": values, "$setOnInsert": {"created_at": now}},
+        upsert=True,
+    )
+    return await membershipsdb.find_one(
+        {
+            "bot_id": str(bot_id),
+            "chat_id": int(chat_id),
+            "user_id": int(user_id),
+        }
+    ) or {}
+
+
+async def list_membership_candidates(user_id: int) -> list[dict[str, Any]]:
+    await ensure_directory_indexes()
+    return await membershipsdb.find(
+        {"user_id": int(user_id), "active": True}
+    ).sort("verified_at", DESCENDING).to_list(length=200)
+
+
+async def enqueue_geocode_job(user_id: int) -> dict[str, Any] | None:
+    await ensure_directory_indexes()
+    location = await get_directory_location(int(user_id))
+    if not location or location.get("geocode_consent") is not True:
+        return None
+    key = str(location["coordinate_key"])
+    cached = await geocode_cachedb.find_one({"coordinate_key": key})
+    if cached:
+        await locationsdb.update_one(
+            {"user_id": int(user_id), "coordinate_key": key},
+            {
+                "$set": {
+                    "region": cached.get("region"),
+                    "geocode_status": "completed",
+                    "geocoded_at": cached.get("created_at"),
+                    "updated_at": utc_now(),
+                }
+            },
+        )
+        return None
+    now = utc_now()
+    await geocode_jobsdb.update_one(
+        {"user_id": int(user_id)},
+        {
+            "$set": {
+                "coordinate_key": key,
+                "longitude": float(location["longitude"]),
+                "latitude": float(location["latitude"]),
+                "status": "pending",
+                "attempts": 0,
+                "next_attempt_at": now,
+                "updated_at": now,
+            },
+            "$setOnInsert": {"created_at": now},
+        },
+        upsert=True,
+    )
+    return await geocode_jobsdb.find_one({"user_id": int(user_id)})
+
+
+async def claim_geocode_job() -> dict[str, Any] | None:
+    await ensure_directory_indexes()
+    now = utc_now()
+    return await geocode_jobsdb.find_one_and_update(
+        {
+            "status": "pending",
+            "$or": [
+                {"next_attempt_at": {"$lte": now}},
+                {"next_attempt_at": {"$exists": False}},
+            ],
+        },
+        {
+            "$set": {
+                "status": "processing",
+                "claimed_at": now,
+                "updated_at": now,
+            },
+            "$inc": {"attempts": 1},
+        },
+        sort=[("created_at", ASCENDING)],
+        return_document=ReturnDocument.AFTER,
+    )
+
+
+async def complete_geocode_job(
+    *,
+    job: dict[str, Any],
+    region: dict[str, Any],
+) -> None:
+    now = utc_now()
+    key = str(job["coordinate_key"])
+    await geocode_cachedb.update_one(
+        {"coordinate_key": key},
+        {
+            "$setOnInsert": {
+                "coordinate_key": key,
+                "region": region,
+                "provider": "nominatim",
+                "created_at": now,
+            }
+        },
+        upsert=True,
+    )
+    await locationsdb.update_one(
+        {
+            "user_id": int(job["user_id"]),
+            "coordinate_key": key,
+            "geocode_consent": True,
+        },
+        {
+            "$set": {
+                "region": region,
+                "geocode_status": "completed",
+                "geocoded_at": now,
+                "updated_at": now,
+            }
+        },
+    )
+    await geocode_jobsdb.delete_one(
+        {"user_id": int(job["user_id"]), "coordinate_key": key}
+    )
+
+
+async def fail_geocode_job(
+    *,
+    job: dict[str, Any],
+    error: str,
+    retry_after_seconds: int = 60,
+) -> None:
+    attempts = int(job.get("attempts") or 1)
+    key_filter = {
+        "user_id": int(job["user_id"]),
+        "coordinate_key": str(job["coordinate_key"]),
+    }
+    if attempts >= 3:
+        await geocode_jobsdb.update_one(
+            key_filter,
+            {
+                "$set": {
+                    "status": "failed",
+                    "error": error[:500],
+                    "updated_at": utc_now(),
+                }
+            },
+        )
+        await locationsdb.update_one(
+            key_filter,
+            {
+                "$set": {
+                    "geocode_status": "failed",
+                    "geocode_error": error[:500],
+                    "updated_at": utc_now(),
+                }
+            },
+        )
+        return
+    await geocode_jobsdb.update_one(
+        key_filter,
+        {
+            "$set": {
+                "status": "pending",
+                "error": error[:500],
+                "next_attempt_at": utc_now()
+                + timedelta(seconds=max(1, retry_after_seconds)),
+                "updated_at": utc_now(),
+            }
+        },
+    )