import './env.mjs'
import { randomBytes, randomUUID } from 'node:crypto'
import { couponJson, executeSql, expertProfileJson, newsArticleJson, productJson, queryJson, sqlLiteral, userJson } from './pg.mjs'
import { generateSmsCode, sendAliyunSmsCode, smsProviderConfigured } from './sms.mjs'
import {
  createUserSessionRecord,
  createStaffSession,
  deletePhoneCodeRecord,
  findActiveUserBySessionToken,
  findActiveStaffAccountByPhone,
  findActiveStaffAccountByToken,
  hasValidPhoneCode,
  logSmsSendRecord,
  storePhoneCode,
  touchAuthRateLimit,
} from './auth-repository.mjs'
import {
  createOperationalEvent,
  findOperationalEventByFingerprint,
  findOperationalEventByKey,
  listOperationalEvents,
  updateOperationalEventById,
} from './operations-repository.mjs'
import {
  createUploadedFileRecord,
  getUploadedFileRecord,
  markUploadedFilesOwner,
  normalizeUploadedFileOwnerType,
} from './uploaded-files-repository.mjs'
import {
  attachInvoiceFileRecord,
  createInvoiceApplication,
  findInvoiceApplicationByKey,
  findUserOrderForInvoice,
  listInvoiceApplicationRows,
  listInvoiceApplications,
  listUserInvoiceApplications,
  updateInvoiceApplicationRecord,
} from './invoice-repository.mjs'
import {
  findPublishedProduct,
  findAgentProductRecord,
  findCourseProductRecord,
  findDataProductRecord,
  findEventProductRecord,
  listAgentCatalogRecord,
  listCourseCatalogRecord,
  listDataCatalogRecord,
  listEventCatalogRecord,
  listMembershipCatalogRecord,
  listPublishedProducts,
} from './products-repository.mjs'
import {
  findAdminOrderRecord,
  findOrderById,
  findUserOrder,
  listAdminOrdersRecord,
  listUserOrders,
} from './orders-repository.mjs'
import {
  createCheckoutOrderRecord,
  createPaymentOrderRecord,
  failPaymentOrderRecord,
  findMiniappPaymentOpenid as findMiniappPaymentOpenidRecord,
  markPaymentSucceededAndGrant,
  markWechatJsapiPaymentPaying,
  markWechatNativePaymentPaying,
} from './checkout-transaction-repository.mjs'
import {
  closeUserPaymentRecord,
  expirePaymentRecord,
  findActivePaymentForOrderChannel,
  findPaymentByNo,
  findUserPaymentByNo,
  listAdminPaymentRecords,
} from './payments-repository.mjs'
import { consumeWechatOAuthState, createWechatOAuthUrl, createWechatOAuthWidgetConfig, exchangeWechatCodeForProfile } from './wechat-oauth.mjs'
import { code2Session, createPcLoginMiniappQr } from './miniapp-wechat.mjs'
import {
  closeWechatPayment,
  createJsapiPaymentParams,
  createWechatJsapiPayment,
  createWechatNativePayment,
  verifyWechatPayNotification,
  wechatPayConfig,
  wechatPayEnabled,
} from './wechat-pay.mjs'
import { incrementMetric } from './observability.mjs'
import { saveBufferToFileStorage } from './file-storage.mjs'

const isProduction = process.env.NODE_ENV === 'production'
const devAuthCode = process.env.DEV_AUTH_CODE || '123456'
const bootstrapSuperAdminPhone = process.env.BOOTSTRAP_SUPER_ADMIN_PHONE || '15842430688'
const devAuthCodeEnabled = envFlag('ENABLE_DEV_AUTH_CODE', !isProduction)
const devPaymentEnabled = envFlag('ENABLE_DEV_PAYMENT', !isProduction)
const devAdminTokenEnabled = envFlag('ENABLE_DEV_ADMIN_TOKEN', !isProduction)
const localDevAdminToken = ['dev', 'admin', 'token'].join('-')
const adminToken = process.env.ADMIN_TOKEN || (devAdminTokenEnabled ? localDevAdminToken : '')
const pcLoginTtlSeconds = Math.max(60, Number(process.env.PC_LOGIN_SESSION_TTL_SECONDS || 300))
const productTypes = new Set(['membership', 'agent', 'data', 'course', 'event', 'expert', 'consulting'])
const productStatuses = new Set(['draft', 'published', 'offline'])
const membershipTiers = new Set(['junior', 'senior'])
const membershipBillingCycles = new Set(['monthly', 'yearly', 'manual_grant'])
const courseAccessStates = new Set(['closed', 'free', 'paid'])
const couponStatuses = new Set(['active', 'paused', 'expired'])
const discountTypes = new Set(['amount', 'percent'])
const taskStatuses = new Set([
  'pending_material',
  'assigned',
  'in_progress',
  'pending_confirm',
  'completed',
  'cancelled',
])
const consultingStatuses = new Set(['pending', 'assigned', 'communicating', 'planning', 'proposal_ready', 'pending_payment', 'in_service', 'completed', 'cancelled'])
const dataServiceStatuses = new Set([
  'pending_contact',
  'contacting',
  'quoted',
  'pending_payment',
  'in_service',
  'completed',
  'cancelled',
])
const notificationChannels = new Set(['in_app', 'sms', 'wechat_subscribe', 'email'])
const notificationStatuses = new Set(['queued', 'sent', 'failed', 'limited', 'skipped'])
const notificationTemplateStatuses = new Set(['active', 'disabled'])
const notificationPriorities = new Set(['low', 'normal', 'high', 'urgent'])
const notificationBatchStatuses = new Set(['draft', 'scheduled', 'sending', 'sent', 'failed', 'cancelled'])
const notificationTargetTypes = new Set(['all', 'user_ids', 'membership_level', 'order_status', 'service_status', 'course_registrations', 'event_registrations', 'experts'])
const newsStatuses = new Set(['draft', 'published', 'offline'])
const expertStatuses = new Set(['draft', 'published', 'offline'])
const expertAvailabilityStatuses = new Set(['this-week', 'next-week', 'unavailable'])
const expertDirectionStatuses = new Set(['active', 'disabled'])
const expertApplicationStatuses = new Set(['pending', 'approved', 'rejected', 'withdrawn'])
const expertBookingStatuses = new Set(['pending_payment', 'pending_expert', 'confirmed', 'rejected', 'cancelled', 'pending_user_confirm', 'disputed', 'completed'])
const expertSlotStatuses = new Set(['open', 'booked', 'closed'])
const expertWithdrawalStatuses = new Set(['pending', 'completed', 'rejected'])
const expertBookingChangeStatuses = new Set(['pending', 'approved', 'requested', 'proposed', 'accepted', 'rejected', 'cancelled'])
const aiAccessLevels = new Set(['denied', 'read', 'operate'])
const aiPermissionCatalog = [
  {
    scopeCode: 'service_discovery',
    groupKey: 'read',
    label: '服务发现',
    description: '推荐、搜索和解释课程、活动、数据服务、智能体、专家和咨询。',
    maxAccessLevel: 'read',
    defaultAccessLevel: 'read',
    sortOrder: 10,
  },
  {
    scopeCode: 'account_overview',
    groupKey: 'read',
    label: '我的概览',
    description: '读取待办数量、服务摘要和需要手动处理的事项。',
    maxAccessLevel: 'read',
    defaultAccessLevel: 'denied',
    sortOrder: 20,
  },
  {
    scopeCode: 'account_membership',
    groupKey: 'read',
    label: '会员权益',
    description: '读取会员等级、到期时间、权益额度和专属服务摘要。',
    maxAccessLevel: 'read',
    defaultAccessLevel: 'denied',
    sortOrder: 30,
  },
  {
    scopeCode: 'account_orders',
    groupKey: 'read',
    label: '我的订单',
    description: '读取订单号、状态、金额摘要和订单页跳转，不读取支付凭证。',
    maxAccessLevel: 'read',
    defaultAccessLevel: 'denied',
    sortOrder: 40,
  },
  {
    scopeCode: 'account_services',
    groupKey: 'read',
    label: '服务进度',
    description: '读取咨询、数据服务和智能体交付进度摘要。',
    maxAccessLevel: 'read',
    defaultAccessLevel: 'denied',
    sortOrder: 50,
  },
  {
    scopeCode: 'account_learning',
    groupKey: 'read',
    label: '课程活动',
    description: '读取课程报名、活动报名和开始时间摘要。',
    maxAccessLevel: 'read',
    defaultAccessLevel: 'denied',
    sortOrder: 60,
  },
  {
    scopeCode: 'account_expert_bookings',
    groupKey: 'read',
    label: '专家预约',
    description: '读取专家预约状态和下一步提示，不读取会议链接或密码。',
    maxAccessLevel: 'read',
    defaultAccessLevel: 'denied',
    sortOrder: 70,
  },
  {
    scopeCode: 'account_invoices',
    groupKey: 'read',
    label: '发票状态',
    description: '读取发票申请状态摘要，不读取税号、邮箱或发票文件。',
    maxAccessLevel: 'read',
    defaultAccessLevel: 'denied',
    sortOrder: 80,
  },
  {
    scopeCode: 'account_notifications',
    groupKey: 'read',
    label: '通知摘要',
    description: '读取站内通知标题和摘要，不自动标记已读。',
    maxAccessLevel: 'read',
    defaultAccessLevel: 'denied',
    sortOrder: 90,
  },
  {
    scopeCode: 'lead_draft',
    groupKey: 'operate',
    label: '申请草稿',
    description: '整理咨询或数据服务申请草稿，并打开页面由你核对提交。',
    maxAccessLevel: 'operate',
    defaultAccessLevel: 'operate',
    sortOrder: 110,
  },
  {
    scopeCode: 'notification_read',
    groupKey: 'operate',
    label: '标记通知已读',
    description: '允许 AI 在你明确要求时标记站内通知已读。',
    maxAccessLevel: 'operate',
    defaultAccessLevel: 'denied',
    sortOrder: 120,
  },
]
const expertBookingTransitionMap = {
  admin: {
    pending_payment: ['cancelled'],
    pending_expert: ['confirmed', 'rejected', 'cancelled'],
    confirmed: ['pending_user_confirm', 'disputed', 'cancelled'],
    pending_user_confirm: ['completed', 'disputed', 'confirmed'],
    disputed: ['completed', 'confirmed', 'cancelled'],
    completed: [],
    cancelled: [],
    rejected: [],
  },
  expert: {
    pending_expert: ['confirmed', 'rejected'],
    confirmed: ['pending_user_confirm'],
  },
  user: {
    pending_payment: ['cancelled'],
    pending_user_confirm: ['completed', 'disputed'],
  },
}
const userStatuses = new Set(['active', 'disabled'])
const paymentChannels = new Set(['dev', 'wechat_jsapi', 'wechat_native'])
const invoiceStatuses = new Set(['pending', 'processing', 'issued', 'rejected', 'failed'])
const activeInvoiceStatuses = new Set(['pending', 'processing', 'issued', 'failed'])
const operationalEventStatuses = new Set(['open', 'acknowledged', 'resolved', 'ignored'])

function canTransitionExpertBooking(actorType, fromStatus, toStatus) {
  if (!toStatus || fromStatus === toStatus) return true
  const actorRules = expertBookingTransitionMap[actorType] || {}
  return (actorRules[fromStatus] || []).includes(toStatus)
}

const defaultStaffRoles = [
  {
    code: 'super_admin',
    name: '超级管理员',
    description: '平台初始化生成的最高权限岗位，负责员工、角色、财务、内容和全部业务配置。',
    defaultEntry: '/admin',
    status: 'active',
    permissions: ['*'],
  },
  {
    code: 'operations_admin',
    name: '运营管理员',
    description: '负责商品、数据页面、优惠码、专家审核、活动报名、合作咨询和订单只读。',
    defaultEntry: '/admin',
    status: 'active',
    permissions: [
      'product:read',
      'product:write',
      'membership_plan:read',
      'membership_plan:write',
      'membership_benefit:read',
      'membership_benefit:write',
      'membership_subscription:read',
      'membership_subscription:update',
      'membership_entitlement:update',
      'membership_content:write',
      'data_page:read',
      'data_page:write',
      'data_service_case:read',
      'data_service_case:write',
      'data_service_page:read',
      'data_service_page:write',
      'data_service_option:read',
      'data_service_option:write',
      'data_service:read',
      'coupon:read',
      'coupon:write',
      'site_recommendation:read',
      'site_recommendation:write',
      'order:read',
      'event:read',
      'event:write',
      'event_registration:read',
      'event_registration:update',
      'event_registration:export',
      'event_checkin:update',
      'course_registration:read',
      'course_checkin:update',
      'cooperation:read',
      'cooperation:update',
      'expert_profile:read',
      'expert_profile:write',
      'expert_application:read',
      'expert_application:review',
      'expert_booking:read',
      'expert_booking:update',
      'expert_schedule:read',
      'expert_schedule:write',
      'user:read',
      'notification:read',
      'notification:write',
      'notification:dispatch',
      'notification_template:update',
      'operations:read',
      'operations:write',
    ],
  },
  {
    code: 'finance_admin',
    name: '财务管理员',
    description: '负责订单、支付、平台收入、专家分账、提现、发票和对账导出。',
    defaultEntry: '/admin/revenue',
    status: 'active',
    permissions: ['order:read', 'payment:read', 'settlement:read', 'withdrawal:update', 'invoice:read', 'invoice:write', 'reconcile:export', 'coupon:read', 'notification:read', 'operations:read'],
  },
  {
    code: 'content_admin',
    name: '内容管理员',
    description: '负责资讯、首页、关于我们、规划咨询、数据页面、协议等站点内容配置。',
    defaultEntry: '/admin/site/home',
    status: 'active',
    permissions: ['news:read', 'news:write', 'expert_profile:read', 'expert_profile:write', 'site:read', 'site:write', 'site_recommendation:read', 'site_recommendation:write', 'data_page:read', 'data_page:write', 'data_service_case:read', 'data_service_case:write', 'data_service_page:read', 'data_service_page:write', 'data_service_option:read', 'data_service_option:write', 'notification:read', 'notification:write', 'notification:dispatch', 'notification_template:update'],
  },
  {
    code: 'data_service_staff',
    name: '数据服务专员',
    description: '负责后台数据服务需求受理、报价、推进和交付。',
    defaultEntry: '/staff?mode=data-service',
    status: 'active',
    permissions: ['data_service:read', 'data_service:update', 'user:read', 'data_page:read', 'data_service_case:read', 'data_service_page:read'],
  },
  {
    code: 'agent_staff',
    name: '履约员工',
    description: '负责员工工作台内的交付任务和数据服务。',
    defaultEntry: '/staff?mode=tasks',
    status: 'active',
    permissions: ['task:read', 'task:update', 'data_service:read', 'data_service:update', 'notification:read'],
  },
  {
    code: 'consultant',
    name: '咨询顾问',
    description: '负责咨询需求、交付任务和数据服务跟进。',
    defaultEntry: '/staff?mode=consulting',
    status: 'active',
    permissions: ['task:read', 'task:update', 'consulting:read', 'consulting:update', 'data_service:read', 'data_service:update'],
  },
  {
    code: 'checkin_staff',
    name: '活动核销员',
    description: '负责课程和活动报名名单查看与核销。',
    defaultEntry: '/admin/events/checkin',
    status: 'active',
    permissions: ['event:read', 'event_registration:read', 'event_checkin:update', 'course_registration:read', 'course_checkin:update'],
  },
]

function metric(name, labels = {}, amount = 1) {
  try {
    incrementMetric(name, labels, amount)
  } catch {}
}

function authRateScope(bucket = '') {
  const parts = String(bucket).split(':')
  if (parts[0] === 'auth' && parts[1]) return `auth:${parts[1]}`
  return 'auth'
}

const rolePermissions = Object.fromEntries(defaultStaffRoles.map((role) => [role.code, role.permissions]))

const permissionCatalog = [
  { code: '*', label: '全部权限', group: '系统', description: '拥有全部后台、员工工作台、组织人员和权限配置能力。' },
  { code: 'staff:read', label: '员工查看', group: '组织人员', description: '查看员工账号、岗位、状态、登录和负责事项。' },
  { code: 'staff:write', label: '员工维护', group: '组织人员', description: '新增、编辑、启用、禁用员工账号。' },
  { code: 'role:read', label: '角色查看', group: '组织人员', description: '查看岗位角色和权限矩阵。' },
  { code: 'role:write', label: '角色维护', group: '组织人员', description: '编辑岗位角色可访问模块和操作权限。' },
  { code: 'org:read', label: '组织架构查看', group: '组织人员', description: '查看部门、岗位、成员归属和继承权限摘要。' },
  { code: 'org:write', label: '组织架构维护', group: '组织人员', description: '维护部门、岗位、员工归属和组织授权。' },
  { code: 'audit:read', label: '操作审计查看', group: '组织人员', description: '查看员工登录、账号变更和权限变更记录。' },
  { code: 'operations:read', label: '异常中心查看', group: '系统运营', description: '查看订单、支付、短信、文件、发票和系统告警异常。' },
  { code: 'operations:write', label: '异常中心处理', group: '系统运营', description: '处理异常事件、标记解决、忽略或发起重试。' },
  { code: 'notification:read', label: '消息查看', group: '系统运营', description: '查看站内信、外部通知队列和人工发送批次。' },
  { code: 'notification:write', label: '消息发布', group: '系统运营', description: '创建人工通知批次并面向目标用户发布站内信。' },
  { code: 'notification:dispatch', label: '消息派发', group: '系统运营', description: '执行通知队列派发、失败重试和外部通道跳过处理。' },
  { code: 'notification_template:update', label: '消息模板维护', group: '系统运营', description: '维护系统通知模板、默认渠道和优先级。' },
  { code: 'user:read', label: '用户查看', group: '用户', description: '查看外部用户、会员、通行证和服务记录。' },
  { code: 'user:write', label: '用户维护', group: '用户', description: '新增、编辑、禁用、恢复外部用户。' },
  { code: 'order:read', label: '订单支付查看', group: '交易财务', description: '查看订单流水、支付记录、平台收入和对账数据。' },
  { code: 'payment:read', label: '支付记录查看', group: '交易财务', description: '查看支付流水、支付渠道和支付状态。' },
  { code: 'settlement:read', label: '财务结算查看', group: '交易财务', description: '查看专家分账、提现申请和结算记录。' },
  { code: 'withdrawal:update', label: '提现申请处理', group: '交易财务', description: '完成或驳回专家提现申请，维护付款流水、银行和凭证。' },
  { code: 'invoice:read', label: '发票申请查看', group: '交易财务', description: '查看用户发票申请、订单和开票状态。' },
  { code: 'invoice:write', label: '发票申请处理', group: '交易财务', description: '处理手动开票、驳回、失败原因和上传发票文件。' },
  { code: 'reconcile:export', label: '对账导出', group: '交易财务', description: '查看并导出按月对账流水。' },
  { code: 'payment:dev', label: '支付处理', group: '交易财务', description: '本地开发支付成功处理和支付相关维护能力。' },
  { code: 'product:read', label: '商品查看', group: '商品内容', description: '查看会员、课程、活动、数据、智能体商品。' },
  { code: 'product:write', label: '商品维护', group: '商品内容', description: '新增、编辑、上下架商品。' },
  { code: 'membership_plan:read', label: '会员方案查看', group: '商品内容', description: '查看会员方案、价格、状态和推荐配置。' },
  { code: 'membership_plan:write', label: '会员方案维护', group: '商品内容', description: '维护会员方案、价格和上下架状态。' },
  { code: 'membership_benefit:read', label: '会员权益查看', group: '商品内容', description: '查看会员权益规则、计划权益和权益说明。' },
  { code: 'membership_benefit:write', label: '会员权益维护', group: '商品内容', description: '维护会员权益规则、计划权益和展示文案。' },
  { code: 'membership_subscription:read', label: '会员订阅查看', group: '用户会员', description: '查看会员订阅、有效期、取消申请、客户经理和提醒记录。' },
  { code: 'membership_subscription:update', label: '会员订阅处理', group: '用户会员', description: '手动赠送、关闭、到期不续和分配客户经理。' },
  { code: 'membership_entitlement:read', label: '会员权益流水查看', group: '用户会员', description: '查看会员额度账户、权益消耗和履约流水。' },
  { code: 'membership_entitlement:update', label: '会员额度调整', group: '商品内容', description: '调整会员额度账户并写入权益流水。' },
  { code: 'membership_content:write', label: '会员内容发布', group: '商品内容', description: '发布会员报告、专属资料和内容入口。' },
  { code: 'passport:read', label: '通行证核验', group: '用户会员', description: '核验前台用户身份、会员状态和服务摘要。' },
  { code: 'data_page:read', label: '数据页面查看', group: '商品内容', description: '查看数据服务页面、案例和样例资料配置。' },
  { code: 'data_page:write', label: '数据页面维护', group: '商品内容', description: '维护数据服务页面、案例和样例资料。' },
  { code: 'data_service_case:read', label: '数据服务案例查看', group: '商品内容', description: '查看数据服务案例和展示状态。' },
  { code: 'data_service_case:write', label: '数据服务案例维护', group: '商品内容', description: '新增、编辑、发布和下线数据服务案例。' },
  { code: 'data_service_page:read', label: '数据服务页面查看', group: '商品内容', description: '查看用户端数据服务页面配置。' },
  { code: 'data_service_page:write', label: '数据服务页面维护', group: '商品内容', description: '维护用户端数据服务页面区块、CTA 和推荐内容。' },
  { code: 'data_service_option:read', label: '数据服务选项查看', group: '商品内容', description: '查看数据服务类型、行业、预算和交付方式字典。' },
  { code: 'data_service_option:write', label: '数据服务选项维护', group: '商品内容', description: '维护数据服务类型、行业、预算和交付方式字典。' },
  { code: 'coupon:read', label: '优惠码查看', group: '营销与站点', description: '查看优惠码、规则和使用记录。' },
  { code: 'coupon:write', label: '优惠码维护', group: '营销与站点', description: '新增、编辑、暂停、删除优惠码。' },
  { code: 'news:read', label: '资讯查看', group: '营销与站点', description: '查看资讯内容。' },
  { code: 'news:write', label: '资讯维护', group: '营销与站点', description: '新增、编辑、发布、下线资讯。' },
  { code: 'news_category:read', label: '资讯分类查看', group: '营销与站点', description: '查看资讯分类、排序和文章数量。' },
  { code: 'news_category:write', label: '资讯分类维护', group: '营销与站点', description: '新增、编辑、禁用资讯分类。' },
  { code: 'site:read', label: '站点配置查看', group: '营销与站点', description: '查看首页、关于我们、规划咨询和协议配置。' },
  { code: 'site:write', label: '站点配置维护', group: '营销与站点', description: '编辑首页、关于我们、规划咨询和协议配置。' },
  { code: 'site_recommendation:read', label: '推荐位查看', group: '营销与站点', description: '查看首页、资讯、课程和活动固定推荐位。' },
  { code: 'site_recommendation:write', label: '推荐位维护', group: '营销与站点', description: '配置首页推荐位和页面轮播位。' },
  { code: 'site_block:read', label: '页面区块查看', group: '营销与站点', description: '查看首页运营区块和区块条目。' },
  { code: 'site_block:write', label: '页面区块维护', group: '营销与站点', description: '维护首页运营区块、排序、图片、跳转和显示状态。' },
  { code: 'cooperation:read', label: '合作咨询查看', group: '营销与站点', description: '查看合作咨询需求。' },
  { code: 'cooperation:update', label: '合作咨询处理', group: '营销与站点', description: '处理合作咨询需求。' },
  { code: 'expert_profile:read', label: '专家资料查看', group: '专家中心', description: '查看专家资源库和专家展示资料。' },
  { code: 'expert_profile:write', label: '专家资料维护', group: '专家中心', description: '新增、编辑、上下线专家展示资料。' },
  { code: 'expert_application:read', label: '专家申请查看', group: '专家中心', description: '查看专家入驻申请。' },
  { code: 'expert_application:review', label: '专家申请审核', group: '专家中心', description: '审核通过或驳回专家入驻申请。' },
  { code: 'expert_booking:read', label: '专家预约查看', group: '专家中心', description: '查看专家预约和改期记录。' },
  { code: 'expert_booking:update', label: '专家预约处理', group: '专家中心', description: '确认、改期、取消和维护专家预约。' },
  { code: 'expert_dispute:update', label: '专家争议处理', group: '专家中心', description: '处理专家预约完成争议和异常锁位。' },
  { code: 'expert_wallet:read', label: '专家钱包查看', group: '专家中心', description: '查看专家可提现、待结算和提现中余额。' },
  { code: 'expert_schedule:read', label: '专家排期查看', group: '专家中心', description: '查看专家开放和关闭时段。' },
  { code: 'expert_schedule:write', label: '专家排期维护', group: '专家中心', description: '新增、调整和关闭专家排期。' },
  { code: 'task:read', label: '交付任务查看', group: '履约', description: '查看员工工作台交付任务。' },
  { code: 'task:update', label: '交付任务处理', group: '履约', description: '接单、流转和完成交付任务。' },
  { code: 'consulting:read', label: '咨询需求查看', group: '履约', description: '查看咨询需求队列。' },
  { code: 'consulting:update', label: '咨询需求处理', group: '履约', description: '接单、沟通、制定方案和完成咨询需求。' },
  { code: 'consulting_option:read', label: '咨询选项查看', group: '履约', description: '查看规划咨询表单字典和方向归属。' },
  { code: 'consulting_option:write', label: '咨询选项维护', group: '履约', description: '维护规划咨询表单字典、排序和状态。' },
  { code: 'data_service:read', label: '数据服务查看', group: '履约', description: '查看数据服务需求。' },
  { code: 'data_service:update', label: '数据服务处理', group: '履约', description: '处理数据服务联系、报价和交付。' },
  { code: 'event:read', label: '报名核销查看', group: '履约', description: '查看课程和活动报名名单。' },
  { code: 'event:write', label: '活动运营维护', group: '履约', description: '新增、编辑、发布、关闭和下线活动。' },
  { code: 'event_registration:read', label: '活动报名查看', group: '履约', description: '查看活动报名名单、报名资料和订单状态。' },
  { code: 'event_registration:update', label: '活动报名处理', group: '履约', description: '补录、取消和维护活动报名记录。' },
  { code: 'event_registration:export', label: '活动报名导出', group: '履约', description: '导出活动报名名单。' },
  { code: 'event:checkin', label: '报名核销处理', group: '履约', description: '核销课程和活动报名。' },
  { code: 'event_checkin:update', label: '现场核销处理', group: '履约', description: '扫码或手动完成现场签到核销。' },
  { code: 'course_registration:read', label: '课程报名查看', group: '履约', description: '查看线下课程报名名单、报名资料和订单状态。' },
  { code: 'course_checkin:update', label: '课程核销处理', group: '履约', description: '扫码或手动完成线下课程签到核销。' },
  { code: 'config_check:read', label: '配置检查查看', group: '系统运营', description: '查看上线前安全、支付、文件和登录配置检查。' },
  { code: 'ops_job:read', label: '运维任务查看', group: '系统运营', description: '查看系统运维任务和最近执行结果。' },
  { code: 'ops_job:run', label: '运维任务执行', group: '系统运营', description: '手动执行允许的系统运维任务。' },
]

const permissionGroupMeta = {
  系统: { domain: 'system', module: 'system', level: 0 },
  组织人员: { domain: 'organization', module: 'staff', level: 10 },
  系统运营: { domain: 'operations', module: 'operations', level: 20 },
  用户: { domain: 'user', module: 'user', level: 30 },
  用户会员: { domain: 'user', module: 'membership', level: 35 },
  交易财务: { domain: 'finance', module: 'finance', level: 40 },
  商品内容: { domain: 'catalog', module: 'product', level: 50 },
  营销与站点: { domain: 'content', module: 'site', level: 60 },
  专家中心: { domain: 'expert', module: 'expert', level: 70 },
  履约: { domain: 'fulfillment', module: 'service', level: 80 },
}

function permissionAction(code = '') {
  const action = String(code).split(':')[1] || 'admin'
  if (action === 'checkin' || action === 'review') return 'update'
  return action
}

function permissionModule(code = '', fallback = '') {
  const [module] = String(code).split(':')
  if (!module || module === '*') return fallback || 'system'
  return module
}

function enrichedPermissionCatalog() {
  return permissionCatalog.map((item, index) => {
    const meta = permissionGroupMeta[item.group] || { domain: 'misc', module: permissionModule(item.code), level: 100 }
    return {
      ...item,
      domain: meta.domain,
      module: permissionModule(item.code, meta.module),
      action: permissionAction(item.code),
      level: meta.level,
      sortOrder: (meta.level * 100) + index,
    }
  })
}

function envFlag(name, defaultValue = false) {
  const value = process.env[name]
  if (value === undefined || value === null || value === '') return defaultValue
  return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase())
}

function sqlTextArray(value) {
  const items = Array.isArray(value)
    ? value
    : String(value || '')
      .split(/\n|[|]{2}|；|;/)
      .map((item) => item.trim())
      .filter(Boolean)
  if (!items.length) return 'array[]::text[]'
  return `array[${items.map((item) => sqlLiteral(item)).join(',')}]::text[]`
}

function sqlUuidArray(value) {
  const items = Array.isArray(value)
    ? value
    : String(value || '')
      .split(/[,\n；;]+/)
      .map((item) => item.trim())
      .filter(Boolean)
  if (!items.length) return 'array[]::uuid[]'
  const validItems = items.filter(isUuid)
  if (!validItems.length) return 'array[]::uuid[]'
  return `array[${validItems.map((item) => `${sqlLiteral(item)}::uuid`).join(',')}]::uuid[]`
}

function sqlJsonb(value) {
  return `${sqlLiteral(JSON.stringify(value || {}))}::jsonb`
}

async function nextBusinessNo(prefix) {
  const result = await queryJson(`
    select json_build_object('value', next_business_no(${sqlLiteral(prefix)}));
  `)
  return result.value
}

function checkinCode(prefix) {
  return `${prefix}${randomUUID().replaceAll('-', '').slice(0, 10).toUpperCase()}`
}

function courseProductJson(productAlias = 'p', detailAlias = 'd') {
  return `json_build_object(
    'id', ${productAlias}.id,
    'productId', ${productAlias}.id,
    'slug', ${productAlias}.slug,
    'type', ${productAlias}.type,
    'title', ${productAlias}.title,
    'summary', ${productAlias}.summary,
    'priceAmount', ${productAlias}.price_amount,
    'purchaseFlow', ${productAlias}.purchase_flow,
    'fulfillmentFlow', ${productAlias}.fulfillment_flow,
    'productStatus', ${productAlias}.status,
    'status', ${detailAlias}.status,
    'instructor', ${detailAlias}.instructor,
    'courseType', ${detailAlias}.course_type,
    'memberLevelRequired', ${detailAlias}.member_level_required,
    'lessonCount', ${detailAlias}.lesson_count,
    'startsAt', ${detailAlias}.starts_at,
    'location', ${detailAlias}.location,
    'courseStatus', ${detailAlias}.course_status,
    'direction', ${detailAlias}.direction,
    'courseFormat', ${detailAlias}.course_format,
    'accessPolicy', ${detailAlias}.access_policy,
    'registeredAccess', ${detailAlias}.registered_access,
    'juniorAccess', ${detailAlias}.junior_access,
    'seniorAccess', ${detailAlias}.senior_access,
    'registeredPriceAmount', ${detailAlias}.registered_price_amount,
    'juniorPriceAmount', ${detailAlias}.junior_price_amount,
    'seniorPriceAmount', ${detailAlias}.senior_price_amount,
    'coverUrl', ${detailAlias}.cover_url,
    'liveUrl', ${detailAlias}.live_url,
    'replayUrl', ${detailAlias}.replay_url,
    'capacity', ${detailAlias}.capacity,
    'registeredCount', coalesce((
      select count(*)::int
      from course_registrations cr
      where cr.product_id = ${productAlias}.id
        and cr.status <> 'cancelled'
    ), 0),
    'checkedInCount', coalesce((
      select count(*)::int
      from course_registrations cr
      where cr.product_id = ${productAlias}.id
        and cr.status = 'checked_in'
    ), 0),
    'checkinRequired', ${detailAlias}.checkin_required,
    'recommended', ${detailAlias}.recommended,
    'coverLabel', ${detailAlias}.cover_label,
    'audience', ${detailAlias}.audience,
    'outcomes', coalesce(to_json(${detailAlias}.outcomes), '[]'::json),
    'modules', coalesce(to_json(${detailAlias}.modules), '[]'::json),
    'lessonModules', coalesce((
      select json_agg(json_build_object(
        'id', cm.id,
        'title', cm.title,
        'description', cm.description,
        'videoUrl', cm.video_url,
        'materialUrl', cm.material_url,
        'durationMinutes', cm.duration_minutes,
        'sortOrder', cm.sort_order,
        'status', cm.status
      ) order by cm.sort_order)
      from course_modules cm
      where cm.product_id = ${productAlias}.id
        and cm.status <> 'deleted'
    ), '[]'::json),
    'accessNote', ${detailAlias}.access_note,
    'sortOrder', ${detailAlias}.sort_order
  )`
}

function dataProductJson(productAlias = 'p', detailAlias = 'd') {
  return `json_build_object(
    'id', ${productAlias}.id,
    'productId', ${productAlias}.id,
    'slug', ${productAlias}.slug,
    'type', ${productAlias}.type,
    'title', ${productAlias}.title,
    'summary', ${productAlias}.summary,
    'priceAmount', ${productAlias}.price_amount,
    'purchaseFlow', ${productAlias}.purchase_flow,
    'fulfillmentFlow', ${productAlias}.fulfillment_flow,
    'productStatus', ${productAlias}.status,
    'status', ${detailAlias}.status,
    'category', ${detailAlias}.category,
    'fileFormat', ${detailAlias}.file_format,
    'coverUrl', ${detailAlias}.cover_url,
    'fileUrl', ${detailAlias}.file_url,
    'fileName', ${detailAlias}.file_name,
    'fileSize', ${detailAlias}.file_size,
    'tags', coalesce(to_json(${detailAlias}.tags), '[]'::json),
    'serviceType', ${detailAlias}.category,
    'industryTags', coalesce(to_json(${detailAlias}.tags), '[]'::json),
    'caseGoal', ${detailAlias}.case_goal,
    'requestOutline', ${detailAlias}.request_outline,
    'solutionOutline', ${detailAlias}.service_note,
    'deliveryOutline', ${detailAlias}.delivery_outline,
    'serviceNote', ${detailAlias}.service_note,
    'ctaLabel', '提交数据服务需求',
    'featuredPosition', ${detailAlias}.featured_position,
    'sortOrder', ${detailAlias}.sort_order
  )`
}

function eventProductJson(productAlias = 'p', detailAlias = 'd') {
  return `json_build_object(
    'id', ${productAlias}.id,
    'productId', ${productAlias}.id,
    'slug', ${productAlias}.slug,
    'type', ${productAlias}.type,
    'title', ${productAlias}.title,
    'summary', ${productAlias}.summary,
    'priceAmount', ${productAlias}.price_amount,
    'purchaseFlow', ${productAlias}.purchase_flow,
    'fulfillmentFlow', ${productAlias}.fulfillment_flow,
    'productStatus', ${productAlias}.status,
    'status', ${detailAlias}.status,
    'eventType', ${detailAlias}.event_type,
    'topic', ${detailAlias}.topic,
    'eventFormat', ${detailAlias}.event_format,
    'coverUrl', ${detailAlias}.cover_url,
    'coverLabel', ${detailAlias}.cover_label,
    'audience', ${detailAlias}.audience,
    'highlights', coalesce(to_json(${detailAlias}.highlights), '[]'::json),
    'agenda', coalesce(to_json(${detailAlias}.agenda), '[]'::json),
    'startsAt', ${detailAlias}.starts_at,
    'endsAt', ${detailAlias}.ends_at,
    'location', ${detailAlias}.location,
    'isOnline', ${detailAlias}.is_online,
    'onlineUrl', ${detailAlias}.online_url,
	    'capacity', ${detailAlias}.capacity,
	    'registeredCount', ${detailAlias}.initial_registered_count + coalesce((
	      select count(*)::int
	      from event_registrations er
	      where er.product_id = ${productAlias}.id
	        and er.status <> 'cancelled'
	    ), 0),
	    'realRegisteredCount', coalesce((
	      select count(*)::int
	      from event_registrations er
	      where er.product_id = ${productAlias}.id
	        and er.status <> 'cancelled'
	    ), 0),
	    'checkedInCount', coalesce((
      select count(*)::int
      from event_registrations er
      where er.product_id = ${productAlias}.id
        and er.status = 'checked_in'
    ), 0),
    'registrationStatus', case
      when ${detailAlias}.status <> 'published' or ${productAlias}.status <> 'published' then 'closed'
      when ${detailAlias}.ends_at is not null and ${detailAlias}.ends_at < now() then 'finished'
      when ${detailAlias}.starts_at is not null and ${detailAlias}.starts_at > now() then 'open'
      when ${detailAlias}.capacity > 0 and coalesce((
        select count(*)::int
        from event_registrations er
        where er.product_id = ${productAlias}.id
          and er.status <> 'cancelled'
      ), 0) >= ${detailAlias}.capacity then 'full'
      else 'open'
    end,
    'registrationStats', json_build_object(
      'total', coalesce((
        select count(*)::int
        from event_registrations er
        where er.product_id = ${productAlias}.id
          and er.status <> 'cancelled'
      ), 0),
      'paid', coalesce((
        select count(*)::int
        from event_registrations er
        join orders o on o.id = er.order_id
        where er.product_id = ${productAlias}.id
          and er.status <> 'cancelled'
          and o.status = 'paid'
      ), 0),
      'waitlisted', 0,
      'checkedIn', coalesce((
        select count(*)::int
        from event_registrations er
        where er.product_id = ${productAlias}.id
          and er.status = 'checked_in'
      ), 0),
      'cancelled', coalesce((
        select count(*)::int
        from event_registrations er
        where er.product_id = ${productAlias}.id
          and er.status = 'cancelled'
      ), 0)
    ),
    'formSchema', json_build_array(
      json_build_object('key', 'name', 'label', '姓名', 'type', 'text', 'required', true),
      json_build_object('key', 'phone', 'label', '手机号', 'type', 'text', 'required', true),
      json_build_object('key', 'organization', 'label', '单位/组织', 'type', 'text', 'required', false),
      json_build_object('key', 'title', 'label', '职务', 'type', 'text', 'required', false)
    ),
    'initialRegisteredCount', ${detailAlias}.initial_registered_count,
    'registeredAccess', ${detailAlias}.registered_access,
    'juniorAccess', ${detailAlias}.junior_access,
    'seniorAccess', ${detailAlias}.senior_access,
    'registeredPriceAmount', ${detailAlias}.registered_price_amount,
    'juniorPriceAmount', ${detailAlias}.junior_price_amount,
    'seniorPriceAmount', ${detailAlias}.senior_price_amount,
    'memberLevelRequired', ${detailAlias}.member_level_required,
    'recommended', ${detailAlias}.recommended,
    'sortOrder', ${detailAlias}.sort_order
  )`
}

function eventRegistrationJson(regAlias = 'er', userAlias = 'u', productAlias = 'p', detailAlias = 'd', orderAlias = 'o', staffAlias = 'sa') {
  return `json_build_object(
    'id', ${regAlias}.id,
    'userId', ${regAlias}.user_id,
    'userName', ${userAlias}.display_name,
    'userPhone', ${userAlias}.phone,
    'userOrganization', ${userAlias}.organization,
    'membershipLevel', ${userAlias}.membership_level,
    'productId', ${regAlias}.product_id,
    'orderId', ${regAlias}.order_id,
    'orderNo', ${orderAlias}.order_no,
    'registrationNo', ${regAlias}.registration_no,
    'status', ${regAlias}.status,
    'checkinCode', ${regAlias}.checkin_code,
    'checkedInAt', ${regAlias}.checked_in_at,
    'checkinSource', coalesce(${regAlias}.checkin_source, ''),
    'checkinNote', coalesce(${regAlias}.checkin_note, ''),
    'checkedInByStaffId', ${regAlias}.checked_in_by_staff_id,
    'checkedInByStaffName', ${staffAlias}.display_name,
    'createdAt', ${regAlias}.created_at,
    'updatedAt', ${regAlias}.updated_at,
    'event', ${eventProductJson(productAlias, detailAlias)}
  )`
}

function courseRegistrationJson(regAlias = 'cr', userAlias = 'u', productAlias = 'p', detailAlias = 'd', orderAlias = 'o', staffAlias = 'sa') {
  return `json_build_object(
    'id', ${regAlias}.id,
    'userId', ${regAlias}.user_id,
    'userName', ${userAlias}.display_name,
    'userPhone', ${userAlias}.phone,
    'userOrganization', ${userAlias}.organization,
    'membershipLevel', ${userAlias}.membership_level,
    'productId', ${regAlias}.product_id,
    'orderId', ${regAlias}.order_id,
    'orderNo', ${orderAlias}.order_no,
    'registrationNo', ${regAlias}.registration_no,
    'status', ${regAlias}.status,
    'checkinCode', ${regAlias}.checkin_code,
    'checkedInAt', ${regAlias}.checked_in_at,
    'checkinSource', coalesce(${regAlias}.checkin_source, ''),
    'checkinNote', coalesce(${regAlias}.checkin_note, ''),
    'checkedInByStaffId', ${regAlias}.checked_in_by_staff_id,
    'checkedInByStaffName', ${staffAlias}.display_name,
    'createdAt', ${regAlias}.created_at,
    'updatedAt', ${regAlias}.updated_at,
    'course', ${courseProductJson(productAlias, detailAlias)}
  )`
}

function normalizeCheckinCode(value) {
  const raw = String(value || '').trim()
  const code = raw.includes(':') ? raw.split(':').pop() : raw
  return String(code || '').trim().toUpperCase()
}

async function auditCheckin({
  account,
  kind,
  registration,
  source,
  method,
  alreadyCheckedIn = false,
} = {}) {
  if (!registration?.id || !kind) return
  const label = kind === 'course' ? '课程' : '活动'
  await auditStaff({
    actorAccount: account,
    action: `${kind}.checkin`,
    targetType: `${kind}_registration`,
    targetId: registration.id,
    summary: `${account?.displayName || account?.phone || '员工'}${alreadyCheckedIn ? '重复核销' : '核销'}${label}报名 ${registration.registrationNo || registration.id}`,
    metadata: {
      projectType: kind,
      projectId: registration.productId,
      registrationId: registration.id,
      registrationNo: registration.registrationNo || '',
      orderNo: registration.orderNo || '',
      userPhone: registration.userPhone || '',
      checkinCode: registration.checkinCode || '',
      source: source || '',
      method: method || '',
      alreadyCheckedIn: Boolean(alreadyCheckedIn),
      checkedInAt: registration.checkedInAt || null,
    },
  })
}

function fulfillmentTaskJson(taskAlias = 'ft', orderAlias = 'o', userAlias = 'u', productAlias = 'p', staffAlias = 'sa') {
  return `json_build_object(
    'id', ${taskAlias}.id,
    'taskNo', ${taskAlias}.task_no,
    'orderId', ${taskAlias}.order_id,
    'orderNo', ${orderAlias}.order_no,
    'userPhone', ${userAlias}.phone,
    'productId', ${taskAlias}.product_id,
    'productTitle', ${productAlias}.title,
    'type', ${taskAlias}.type,
    'title', ${taskAlias}.title,
    'status', ${taskAlias}.status,
    'priority', ${taskAlias}.priority,
    'assigneeAccountId', ${taskAlias}.assignee_account_id,
    'assigneeName', ${staffAlias}.display_name,
    'acceptedAt', ${taskAlias}.accepted_at,
    'progressNote', ${taskAlias}.progress_note,
    'resultSummary', ${taskAlias}.result_summary,
    'materialPayload', coalesce(${taskAlias}.material_payload, '{}'::jsonb),
    'completedAt', ${taskAlias}.completed_at,
    'createdAt', ${taskAlias}.created_at,
    'updatedAt', ${taskAlias}.updated_at
  )`
}

function normalizeCourseAccess(value, fallback = 'closed') {
  return courseAccessStates.has(value) ? value : fallback
}

function toSafeMoney(value) {
  const amount = Number(value)
  if (!Number.isFinite(amount)) return 0
  return Math.max(0, Math.round(amount))
}

function toSafeInteger(value, fallback = 0) {
  const numberValue = Number(value)
  if (!Number.isFinite(numberValue)) return fallback
  return Math.round(numberValue)
}

export function normalizeManagedAssetUrl(value, label = '图片') {
  const raw = String(value || '').trim()
  if (!raw) return ''
  if (raw.startsWith('/api/files/')) return raw
  if (/^https?:\/\//i.test(raw)) {
    try {
      const parsed = new URL(raw)
      if (parsed.pathname.startsWith('/api/files/')) return raw
    } catch {
      // Fall through to the unified validation error below.
    }
  }
  throw httpError(`${label}请通过后台上传后保存，不能直接使用外链或前端静态资源`, 400, 'managed_asset_required')
}

function productSortOrder(value, fallback = 0) {
  return Math.max(0, toSafeInteger(value, fallback))
}

async function shiftProductSortOrder(detailTable, productId, sortOrder) {
  const tableNames = new Set(['agent_product_details', 'course_product_details', 'data_product_details', 'event_product_details'])
  if (!tableNames.has(detailTable)) {
    const error = new Error('商品排序表无效')
    error.statusCode = 500
    throw error
  }

  const current = await queryJson(`
    select json_build_object('sortOrder', sort_order)
    from ${detailTable}
    where product_id = ${sqlLiteral(productId)}
    limit 1;
  `)
  if (current?.sortOrder === sortOrder) return

  await executeSql(`
    update ${detailTable}
    set sort_order = sort_order + 1,
        updated_at = now()
    where product_id <> ${sqlLiteral(productId)}
      and status <> 'deleted'
      and sort_order >= ${sortOrder};
  `)
}

function legacyAccessPolicyFromMatrix(registeredAccess, juniorAccess, seniorAccess) {
  if (registeredAccess === 'free' && juniorAccess === 'free' && seniorAccess === 'free') return 'free_all'
  if (registeredAccess === 'paid' && juniorAccess === 'paid' && seniorAccess === 'paid') return 'paid_all'
  if (registeredAccess === 'paid' && juniorAccess === 'free' && seniorAccess === 'free') return 'member_free_guest_paid'
  if (registeredAccess === 'closed' && juniorAccess === 'free' && seniorAccess === 'free') return 'members_only_free'
  if (registeredAccess === 'closed' && juniorAccess === 'paid' && seniorAccess === 'free') return 'junior_paid_senior_free'
  if (registeredAccess === 'closed' && juniorAccess === 'closed' && seniorAccess === 'free') return 'senior_only_free'
  return 'paid_all'
}

function courseAccessPrice(accessState, priceAmount) {
  if (accessState === 'closed') return null
  if (accessState === 'free') return 0
  return Math.max(0, Math.round(Number(priceAmount || 0)))
}

function courseMatrixDisplayPrice(registeredAccess, registeredPriceAmount, juniorAccess, juniorPriceAmount, seniorAccess, seniorPriceAmount) {
  const prices = [
    courseAccessPrice(registeredAccess, registeredPriceAmount),
    courseAccessPrice(juniorAccess, juniorPriceAmount),
    courseAccessPrice(seniorAccess, seniorPriceAmount),
  ].filter((value) => value !== null)
  if (!prices.length) return 0
  return Math.min(...prices)
}

function courseAccessForMembership(course, level) {
  if (level === 'senior') return course.seniorAccess
  if (level === 'junior') return course.juniorAccess
  return course.registeredAccess
}

function coursePriceForMembership(course, level) {
  if (level === 'senior') return course.seniorPriceAmount
  if (level === 'junior') return course.juniorPriceAmount
  return course.registeredPriceAmount
}

function agentAccessForMembership(agent, level) {
  if (level === 'senior') return agent.seniorAccess
  if (level === 'junior') return agent.juniorAccess
  return agent.registeredAccess
}

function agentPriceForMembership(agent, level) {
  if (level === 'senior') return agent.seniorPriceAmount
  if (level === 'junior') return agent.juniorPriceAmount
  return agent.registeredPriceAmount
}

function eventAccessForMembership(eventProduct, level) {
  if (level === 'senior') return eventProduct.seniorAccess
  if (level === 'junior') return eventProduct.juniorAccess
  return eventProduct.registeredAccess
}

function eventPriceForMembership(eventProduct, level) {
  if (level === 'senior') return eventProduct.seniorPriceAmount
  if (level === 'junior') return eventProduct.juniorPriceAmount
  return eventProduct.registeredPriceAmount
}

function agentMatrixDisplayPrice(registeredAccess, registeredPriceAmount, juniorAccess, juniorPriceAmount, seniorAccess, seniorPriceAmount) {
  const prices = [
    courseAccessPrice(registeredAccess, registeredPriceAmount),
    courseAccessPrice(juniorAccess, juniorPriceAmount),
    courseAccessPrice(seniorAccess, seniorPriceAmount),
  ].filter((value) => value !== null)
  if (!prices.length) return 0
  return Math.min(...prices)
}

function assertPaidAgentPrice(label, accessState, priceAmount) {
  if (accessState !== 'paid' || priceAmount > 0) return
  const error = new Error(`${label}选择收费时，必须填写大于 0 的价格`)
  error.statusCode = 400
  error.code = 'invalid_agent_price'
  throw error
}

export function assertPaidCoursePrice(label, accessState, priceAmount) {
  if (accessState !== 'paid' || priceAmount > 0) return
  const error = new Error(`${label}选择收费时，必须填写大于 0 的课程价格`)
  error.statusCode = 400
  error.code = 'invalid_course_price'
  throw error
}

function assertPaidEventPrice(label, accessState, priceAmount) {
  if (accessState !== 'paid' || priceAmount > 0) return
  const error = new Error(`${label}选择收费时，必须填写大于 0 的报名价格`)
  error.statusCode = 400
  error.code = 'invalid_event_price'
  throw error
}

function normalizeCourseModules(lessonModules, legacyModules) {
  if (Array.isArray(lessonModules) && lessonModules.length) {
    return lessonModules
      .map((item, index) => ({
        id: item.id || '',
        title: String(item.title || '').trim(),
        description: String(item.description || '').trim(),
        videoUrl: normalizeManagedAssetUrl(item.videoUrl || '', '课程视频文件'),
        materialUrl: normalizeManagedAssetUrl(item.materialUrl || '', '课程资料文件'),
        durationMinutes: Number(item.durationMinutes || 0),
        sortOrder: Number(item.sortOrder || (index + 1) * 10),
        status: item.status || 'published',
      }))
      .filter((item) => item.title)
  }
  const titles = Array.isArray(legacyModules) ? legacyModules : String(legacyModules || '').split('\n')
  return titles
    .map((title, index) => ({
      title: String(title || '').trim(),
      description: '',
      videoUrl: '',
      materialUrl: '',
      durationMinutes: 0,
      sortOrder: (index + 1) * 10,
      status: 'published',
    }))
    .filter((item) => item.title)
}

export function publicUser(user) {
  return user || null
}

function normalizePhone(phone) {
  return String(phone || '').trim()
}

function normalizeWechatId(value) {
  return String(value || '').trim()
}

function normalizePassportNo(value) {
  return String(value || '').trim().toUpperCase()
}

function assertValidPhone(phone) {
  if (!/^1\d{10}$/.test(phone)) {
    const error = new Error('请填写有效手机号')
    error.statusCode = 400
    error.code = 'invalid_phone'
    throw error
  }
}

function secureToken(prefix, bytes = 18) {
  return `${prefix}_${randomBytes(bytes).toString('hex')}`
}

function sanitizeFrontendPath(value) {
  const path = String(value || '/').trim()
  if (!path || !path.startsWith('/') || path.startsWith('//')) return '/'
  return path.slice(0, 512)
}

async function assertValidPhoneCode(phone, code) {
  const valid = await hasValidPhoneCode(phone, code)
  if (!valid) {
    const error = new Error('验证码无效或已过期')
    error.statusCode = 401
    error.code = 'invalid_code'
    throw error
  }
}

async function deletePhoneCode(phone, code) {
  await deletePhoneCodeRecord(phone, code)
}

function normalizeRatePart(value) {
  return String(value || 'unknown')
    .trim()
    .slice(0, 160)
    .replace(/[^a-zA-Z0-9_.:@-]/g, '_') || 'unknown'
}

async function enforceAuthRateLimit(bucket, limit, windowSeconds, message) {
  const safeLimit = Math.max(1, Number(limit) || 1)
  const safeWindowSeconds = Math.max(1, Number(windowSeconds) || 60)
  const result = await touchAuthRateLimit(bucket, safeWindowSeconds)

  if (Number(result?.attempts || 0) > safeLimit) {
    const retryAfter = Math.max(1, Math.ceil((new Date(result.expiresAt).getTime() - Date.now()) / 1000)) || safeWindowSeconds
    metric('dlbdi_auth_rate_limit_hits_total', { scope: authRateScope(bucket) })
    const error = new Error(message || '请求过于频繁，请稍后再试')
    error.statusCode = 429
    error.code = 'rate_limited'
    error.retryAfter = retryAfter
    throw error
  }
}

async function enforceAuthRateLimits(rules) {
  for (const rule of rules) {
    await enforceAuthRateLimit(rule.bucket, rule.limit, rule.windowSeconds, rule.message)
  }
}

async function logSmsSend(phone, status, details = {}, options = {}) {
  await logSmsSendRecord(phone, status, details, options)
}

function authClientKey(options = {}) {
  return normalizeRatePart(options.clientIp || options.ip || 'unknown')
}

function nextUserAction(user) {
  if (!user?.phone) return 'bind_phone'
  if (!user.onboardingCompleted) return 'complete_profile'
  return 'enter_app'
}

function requirePcSessionId(payload = {}) {
  const sessionId = String(payload.sessionId || payload.session_id || '').trim()
  if (!sessionId) {
    const error = new Error('电脑端登录会话无效')
    error.statusCode = 400
    error.code = 'pc_login_session_required'
    throw error
  }
  return sessionId
}

function sanitizePcClientText(value) {
  return String(value || '')
    .trim()
    .replace(/\s+/g, ' ')
    .slice(0, 80)
}

function normalizePcClientContext(payload = {}) {
  const source = payload.client || payload.clientInfo || payload.client_info || payload || {}
  return {
    region: sanitizePcClientText(source.region || source.clientRegion || source.client_region),
    device: sanitizePcClientText(source.device || source.clientDevice || source.client_device),
    browser: sanitizePcClientText(source.browser || source.clientBrowser || source.client_browser),
  }
}

async function updatePcSessionStatus(sessionId, status, userId = null, exchangeCode = null) {
  const id = String(sessionId || '').trim()
  if (!id) return null
  const setConfirmed = status === 'confirmed' ? 'confirmed_at = now(),' : ''
  return queryJson(`
    update pc_login_sessions
    set status = case when expires_at <= now() then 'expired' else ${sqlLiteral(status)} end,
        user_id = coalesce(${userId ? `${sqlLiteral(userId)}::uuid` : 'null'}, user_id),
        exchange_code = coalesce(${exchangeCode ? sqlLiteral(exchangeCode) : 'null'}, exchange_code),
        ${setConfirmed}
        updated_at = now()
    where session_id = ${sqlLiteral(id)}
      and status not in ('confirmed', 'cancelled')
    returning json_build_object(
      'sessionId', session_id,
      'status', status,
      'redirect', redirect,
      'exchangeCode', exchange_code,
      'expiresAt', expires_at,
      'client', json_build_object(
        'region', client_region,
        'device', client_device,
        'browser', client_browser
      )
    );
  `)
}

function normalizedBootstrapSuperAdminPhone() {
  return normalizePhone(bootstrapSuperAdminPhone)
}

function assertSuperAdminPhone(role, phone) {
  if (role !== 'super_admin') return
  if (normalizePhone(phone) === normalizedBootstrapSuperAdminPhone()) return
  const error = new Error(`超级管理员只能使用初始化手机号 ${normalizedBootstrapSuperAdminPhone()}`)
  error.statusCode = 403
  error.code = 'super_admin_phone_locked'
  throw error
}

async function createUserSession(userId, token = `lite_${randomUUID()}`) {
  const result = await createUserSessionRecord(userId, token)
  if (!result) {
    const error = new Error('账户已被禁用或不存在')
    error.statusCode = 403
    error.code = 'user_disabled'
    throw error
  }
  return result
}

export async function sendPhoneCode(phone, options = {}) {
  phone = normalizePhone(phone)
  assertValidPhone(phone)
  const clientKey = authClientKey(options)
  try {
    await enforceAuthRateLimits([
      {
        bucket: `auth:sms:phone:${normalizeRatePart(phone)}:minute`,
        limit: 1,
        windowSeconds: 60,
        message: '验证码发送太频繁，请 60 秒后再试',
      },
      {
        bucket: `auth:sms:phone:${normalizeRatePart(phone)}:hour`,
        limit: 5,
        windowSeconds: 3600,
        message: '该手机号验证码发送次数过多，请稍后再试',
      },
      {
        bucket: `auth:sms:ip:${clientKey}:hour`,
        limit: 30,
        windowSeconds: 3600,
        message: '验证码请求过于频繁，请稍后再试',
      },
    ])
  } catch (error) {
    await logSmsSend(phone, 'limited', { failureReason: error.message }, options)
    metric('dlbdi_sms_send_total', { status: 'limited', provider: smsProviderConfigured() ? 'aliyun' : 'dev' })
    throw error
  }
  const useSmsProvider = smsProviderConfigured()

  if (!useSmsProvider && !devAuthCodeEnabled) {
    await logSmsSend(phone, 'failed', { failureReason: 'sms_provider_not_configured' }, options)
    metric('dlbdi_sms_send_total', { status: 'failed', provider: 'none' })
    await recordOperationalEvent({
      severity: 'error',
      eventType: 'sms.send.failed',
      resourceType: 'phone',
      resourceId: phone,
      message: '短信服务未配置，验证码无法发送',
      payload: { reason: 'sms_provider_not_configured' },
    })
    const error = new Error('短信服务尚未接入，无法发送验证码')
    error.statusCode = 503
    error.code = 'sms_provider_not_configured'
    throw error
  }

  const code = useSmsProvider ? generateSmsCode() : devAuthCode
  await storePhoneCode(phone, code, 600)

  if (useSmsProvider) {
    try {
      const result = await sendAliyunSmsCode(phone, code)
      await logSmsSend(phone, 'sent', { requestId: result.requestId || '' }, options)
      metric('dlbdi_sms_send_total', { status: 'sent', provider: 'aliyun' })
    } catch (error) {
      await deletePhoneCode(phone, code)
      await logSmsSend(phone, 'failed', { failureReason: error.message, requestId: error.details?.requestId || '' }, options)
      metric('dlbdi_sms_send_total', { status: 'failed', provider: 'aliyun' })
      await recordOperationalEvent({
        severity: 'error',
        eventType: 'sms.send.failed',
        resourceType: 'phone',
        resourceId: phone,
        message: error.message || '短信发送失败',
        payload: {
          requestId: error.details?.requestId || '',
          provider: 'aliyun',
        },
      })
      throw error
    }
  } else {
    await logSmsSend(phone, 'skipped', { provider: 'dev' }, options)
    metric('dlbdi_sms_send_total', { status: 'skipped', provider: 'dev' })
  }

  return {
    phone,
    expiresIn: 600,
    ...(!isProduction && devAuthCodeEnabled && !useSmsProvider ? { demoCode: code } : {}),
  }
}

export async function loginByPhone(phone, code, options = {}) {
  phone = normalizePhone(phone)
  assertValidPhone(phone)
  const cleanCode = String(code || '').trim()
  const clientKey = authClientKey(options)
  await enforceAuthRateLimits([
    {
      bucket: `auth:user-login:phone:${normalizeRatePart(phone)}:15m`,
      limit: 10,
      windowSeconds: 900,
      message: '验证码尝试次数过多，请稍后再试',
    },
    {
      bucket: `auth:user-login:ip:${clientKey}:15m`,
      limit: 60,
      windowSeconds: 900,
      message: '登录请求过于频繁，请稍后再试',
    },
  ])
  try {
    await assertValidPhoneCode(phone, cleanCode)
  } catch (error) {
    metric('dlbdi_login_attempts_total', { actor: 'user', status: 'failed', reason: error.code || 'invalid_code' })
    throw error
  }

  const user = await queryJson(`
    select ${userJson('u')}
    from users u
    where u.phone = ${sqlLiteral(phone)}
      and u.status = 'active'
    limit 1;
  `)
  if (!user) {
    metric('dlbdi_login_attempts_total', { actor: 'user', status: 'failed', reason: 'phone_not_registered' })
    const error = new Error('该手机号尚未注册，请使用微信扫码在小程序完成注册')
    error.statusCode = 404
    error.code = 'phone_not_registered'
    throw error
  }
  await deletePhoneCode(phone, cleanCode)
  metric('dlbdi_login_attempts_total', { actor: 'user', status: 'succeeded', reason: 'ok' })
  return createUserSession(user.id)
}

export async function loginByWechat(wechatId, payload = {}) {
  wechatId = normalizeWechatId(wechatId)
  const phone = normalizePhone(payload.phone)
  if (!wechatId) {
    const error = new Error('请填写微信号')
    error.statusCode = 400
    error.code = 'invalid_wechat_id'
    throw error
  }
  if (phone) assertValidPhone(phone)

  if (phone) {
    const phoneUser = await queryJson(`
      select ${userJson('u')}
      from users u
      where u.phone = ${sqlLiteral(phone)}
      limit 1;
    `)
    if (phoneUser) {
      if (phoneUser.wechatId && phoneUser.wechatId !== wechatId) {
        const error = new Error('该手机号已绑定其他微信号')
        error.statusCode = 409
        error.code = 'phone_wechat_conflict'
        throw error
      }
      await executeSql(`
        update users
        set wechat_id = ${sqlLiteral(wechatId)},
            updated_at = now()
        where id = ${sqlLiteral(phoneUser.id)}::uuid;
      `)
      return createUserSession(phoneUser.id)
    }
  }

  const wechatUser = await queryJson(`
    select ${userJson('u')}
    from users u
    where u.wechat_id = ${sqlLiteral(wechatId)}
    limit 1;
  `)
  if (wechatUser) {
    if (phone && !wechatUser.phone) {
      await executeSql(`
        update users
        set phone = ${sqlLiteral(phone)},
            updated_at = now()
        where id = ${sqlLiteral(wechatUser.id)}::uuid;
      `)
    }
    return createUserSession(wechatUser.id)
  }

  const created = await queryJson(`
    insert into users (phone, wechat_id, display_name)
    values (
      ${phone ? sqlLiteral(phone) : 'null'},
      ${sqlLiteral(wechatId)},
      ${sqlLiteral(wechatId)}
    )
    returning ${userJson('users')};
  `)
  return createUserSession(created.id)
}

export function startWechatOAuth(redirect = '/') {
  return createWechatOAuthUrl(redirect)
}

export function getWechatOAuthQrConfig(redirect = '/') {
  return createWechatOAuthWidgetConfig(redirect)
}

export async function completeWechatOAuth(code, state) {
  const { redirect } = consumeWechatOAuthState(state)
  const profile = await exchangeWechatCodeForProfile(code)
  const wechatId = normalizeWechatId(profile.unionid || profile.openid)
  const session = await loginByWechat(wechatId)

  if (profile.nickname && session.user?.id) {
    const updatedUser = await queryJson(`
      update users
      set display_name = case
            when coalesce(nullif(trim(display_name), ''), '') = ''
              or display_name = ${sqlLiteral(wechatId)}
            then ${sqlLiteral(profile.nickname)}
            else display_name
          end,
          updated_at = now()
      where id = ${sqlLiteral(session.user.id)}::uuid
      returning ${userJson('users')};
    `)
    if (updatedUser) session.user = updatedUser
  }

  return {
    ...session,
    redirect,
    wechatProfile: {
      openid: profile.openid,
      unionid: profile.unionid,
      nickname: profile.nickname,
      avatarUrl: profile.avatarUrl,
      scope: profile.scope,
    },
  }
}

async function getMiniappIdentity(appId, openid) {
  return queryJson(`
    select json_build_object(
      'id', i.id,
      'userId', i.user_id,
      'appId', i.app_id,
      'openid', i.openid,
      'unionid', i.unionid,
      'user', ${userJson('u')}
    )
    from user_wechat_identities i
    join users u on u.id = i.user_id
    where i.provider = 'miniapp'
      and i.app_id = ${sqlLiteral(appId)}
      and i.openid = ${sqlLiteral(openid)}
    limit 1;
  `)
}

async function createMiniappAuthSession(auth, pcSessionId = '') {
  const token = secureToken('miniapp')
  await executeSql(`
    insert into miniapp_auth_sessions (token, app_id, openid, unionid, pc_session_id, expires_at)
    values (
      ${sqlLiteral(token)},
      ${sqlLiteral(auth.appId)},
      ${sqlLiteral(auth.openid)},
      ${sqlLiteral(auth.unionid || '')},
      ${pcSessionId ? sqlLiteral(pcSessionId) : 'null'},
      now() + interval '30 minutes'
    );
  `)
  return token
}

async function getMiniappAuthSession(token) {
  const session = await queryJson(`
    select json_build_object(
      'token', token,
      'appId', app_id,
      'openid', openid,
      'unionid', unionid,
      'pcSessionId', pc_session_id,
      'expiresAt', expires_at
    )
    from miniapp_auth_sessions
    where token = ${sqlLiteral(String(token || '').trim())}
      and expires_at > now()
    limit 1;
  `)
  if (!session) {
    const error = new Error('微信登录会话已过期，请重新登录')
    error.statusCode = 401
    error.code = 'miniapp_session_expired'
    throw error
  }
  return session
}

export async function miniappWechatLogin(payload = {}, options = {}) {
  await enforceAuthRateLimit(
    `auth:miniapp-login:ip:${authClientKey(options)}:15m`,
    80,
    900,
    '微信登录请求过于频繁，请稍后再试',
  )
  const auth = await code2Session(payload.jsCode || payload.code)
  const pcSessionId = String(payload.pcSessionId || payload.pc_session_id || '').trim()
  if (pcSessionId) await updatePcSessionStatus(pcSessionId, 'scanned')

  const identity = await getMiniappIdentity(auth.appId, auth.openid)
  if (!identity) {
    const miniappSessionToken = await createMiniappAuthSession(auth, pcSessionId)
    if (pcSessionId) await updatePcSessionStatus(pcSessionId, 'need_phone')
    return {
      token: '',
      user: null,
      miniappSessionToken,
      nextAction: 'bind_phone',
      pcSessionId,
    }
  }

  await executeSql(`
    update user_wechat_identities
    set unionid = coalesce(nullif(${sqlLiteral(auth.unionid || '')}, ''), unionid),
        last_login_at = now(),
        updated_at = now()
    where id = ${sqlLiteral(identity.id)}::uuid;
  `)

  const session = await createUserSession(identity.userId)
  const nextAction = nextUserAction(session.user)
  const miniappSessionToken = nextAction === 'bind_phone' ? await createMiniappAuthSession(auth, pcSessionId) : ''
  if (pcSessionId) {
    await updatePcSessionStatus(pcSessionId, nextAction === 'complete_profile' ? 'need_profile' : nextAction === 'enter_app' ? 'confirming' : 'need_phone', session.user.id)
  }
  return {
    ...session,
    miniappSessionToken,
    nextAction,
    pcSessionId,
  }
}

export async function bindMiniappPhone(payload = {}, options = {}) {
  const miniappSession = await getMiniappAuthSession(payload.miniappSessionToken || payload.miniapp_session_token)
  const phone = normalizePhone(payload.phone)
  assertValidPhone(phone)
  const cleanCode = String(payload.code || '').trim()
  const clientKey = authClientKey(options)
  await enforceAuthRateLimits([
    {
      bucket: `auth:miniapp-bind:phone:${normalizeRatePart(phone)}:15m`,
      limit: 10,
      windowSeconds: 900,
      message: '验证码尝试次数过多，请稍后再试',
    },
    {
      bucket: `auth:miniapp-bind:ip:${clientKey}:15m`,
      limit: 60,
      windowSeconds: 900,
      message: '绑定请求过于频繁，请稍后再试',
    },
  ])
  try {
    await assertValidPhoneCode(phone, cleanCode)
  } catch (error) {
    metric('dlbdi_login_attempts_total', { actor: 'staff', status: 'failed', reason: error.code || 'invalid_code' })
    throw error
  }

  const existingIdentityForPhone = await queryJson(`
    select json_build_object('openid', i.openid, 'userId', i.user_id)
    from user_wechat_identities i
    join users u on u.id = i.user_id
    where i.provider = 'miniapp'
      and i.app_id = ${sqlLiteral(miniappSession.appId)}
      and u.phone = ${sqlLiteral(phone)}
      and i.openid <> ${sqlLiteral(miniappSession.openid)}
    limit 1;
  `)
  if (existingIdentityForPhone) {
    const error = new Error('该手机号已绑定其他微信，请联系客服处理')
    error.statusCode = 409
    error.code = 'phone_wechat_conflict'
    throw error
  }

  const user = await queryJson(`
    with upserted_user as (
      insert into users (phone, contact_phone)
      values (${sqlLiteral(phone)}, ${sqlLiteral(phone)})
      on conflict (phone) do update set
        updated_at = now(),
        contact_phone = coalesce(nullif(users.contact_phone, ''), excluded.contact_phone)
      returning *
    )
    select ${userJson('upserted_user')}
    from upserted_user;
  `)

  await executeSql(`
    insert into user_wechat_identities (user_id, provider, app_id, openid, unionid, last_login_at)
    values (
      ${sqlLiteral(user.id)}::uuid,
      'miniapp',
      ${sqlLiteral(miniappSession.appId)},
      ${sqlLiteral(miniappSession.openid)},
      ${sqlLiteral(miniappSession.unionid || '')},
      now()
    )
    on conflict (provider, app_id, openid) do update set
      user_id = excluded.user_id,
      unionid = coalesce(nullif(excluded.unionid, ''), user_wechat_identities.unionid),
      last_login_at = now(),
      updated_at = now();

    delete from miniapp_auth_sessions
    where token = ${sqlLiteral(miniappSession.token)};
  `)
  await deletePhoneCode(phone, cleanCode)

  const session = await createUserSession(user.id)
  const nextAction = nextUserAction(session.user)
  if (miniappSession.pcSessionId) {
    await updatePcSessionStatus(miniappSession.pcSessionId, nextAction === 'complete_profile' ? 'need_profile' : 'confirming', session.user.id)
  }
  return {
    ...session,
    nextAction,
    pcSessionId: miniappSession.pcSessionId || '',
  }
}

export async function createPcScanLoginSession(payload = {}, options = {}) {
  await enforceAuthRateLimit(
    `auth:pc-scan:create:ip:${authClientKey(options)}:10m`,
    40,
    600,
    '二维码创建过于频繁，请稍后再试',
  )
  const sessionId = randomBytes(10).toString('hex')
  const redirect = sanitizeFrontendPath(payload.redirect || '/')
  const client = normalizePcClientContext(payload)
  const scene = `pc_${sessionId}`
  await executeSql(`
    insert into pc_login_sessions (session_id, scene, redirect, client_region, client_device, client_browser, expires_at)
    values (
      ${sqlLiteral(sessionId)},
      ${sqlLiteral(scene)},
      ${sqlLiteral(redirect)},
      ${sqlLiteral(client.region)},
      ${sqlLiteral(client.device)},
      ${sqlLiteral(client.browser)},
      now() + (${sqlLiteral(`${pcLoginTtlSeconds} seconds`)})::interval
    );
  `)
  const qrImage = await createPcLoginMiniappQr(scene)
  const storedQr = await saveBufferToFileStorage(qrImage.buffer, `pc-login-${sessionId}.png`, qrImage.mimeType)
  const qrFile = await createUploadedFileRecord({
    ...storedQr,
    mimeType: qrImage.mimeType,
    contentBase64: '',
    ownerType: 'site_asset',
    ownerId: `pc_login:${sessionId}`,
    visibility: 'public',
  })
  const qrUrl = qrFile.url
  return {
    sessionId,
    scene,
    qrDataUrl: qrUrl,
    qrUrl,
    expiresIn: pcLoginTtlSeconds,
  }
}

export async function getPcScanLoginStatus(sessionId) {
  const id = String(sessionId || '').trim()
  const session = await queryJson(`
    update pc_login_sessions
    set status = case when expires_at <= now() and status not in ('confirmed', 'cancelled') then 'expired' else status end,
        updated_at = case when expires_at <= now() and status not in ('confirmed', 'cancelled') then now() else updated_at end
    where session_id = ${sqlLiteral(id)}
    returning json_build_object(
      'sessionId', session_id,
      'status', status,
      'redirect', redirect,
      'exchangeCode', case when status = 'confirmed' then exchange_code else null end,
      'expiresAt', expires_at,
      'client', json_build_object(
        'region', client_region,
        'device', client_device,
        'browser', client_browser
      )
    );
  `)
  if (!session) {
    const error = new Error('扫码登录会话不存在')
    error.statusCode = 404
    error.code = 'pc_login_session_not_found'
    throw error
  }
  return session
}

export async function markPcScanLoginScanned(payload = {}, options = {}) {
  await enforceAuthRateLimit(
    `auth:pc-scan:scanned:ip:${authClientKey(options)}:10m`,
    120,
    600,
    '扫码状态更新过于频繁，请稍后再试',
  )
  const sessionId = requirePcSessionId(payload)
  const updated = await queryJson(`
    update pc_login_sessions
    set status = case
          when expires_at <= now() then 'expired'
          when status = 'pending' then 'scanned'
          else status
        end,
        updated_at = case
          when expires_at <= now() or status = 'pending' then now()
          else updated_at
        end
    where session_id = ${sqlLiteral(sessionId)}
      and status not in ('confirmed', 'cancelled')
    returning json_build_object(
      'sessionId', session_id,
      'status', status,
      'redirect', redirect,
      'exchangeCode', case when status = 'confirmed' then exchange_code else null end,
      'expiresAt', expires_at,
      'client', json_build_object(
        'region', client_region,
        'device', client_device,
        'browser', client_browser
      )
    );
  `)
  if (!updated) {
    const error = new Error('扫码登录会话已结束，请刷新电脑端二维码')
    error.statusCode = 409
    error.code = 'pc_login_session_closed'
    throw error
  }
  if (updated.status === 'expired') {
    const error = new Error('扫码登录已过期，请刷新电脑端二维码')
    error.statusCode = 410
    error.code = 'pc_login_expired'
    throw error
  }
  return { ok: true, status: updated.status }
}

export async function preparePcScanLogin(token, payload = {}) {
  const { user } = await requireUser(token)
  const sessionId = requirePcSessionId(payload)
  const nextAction = nextUserAction(user)
  const nextStatus = nextAction === 'bind_phone' ? 'need_phone' : nextAction === 'complete_profile' ? 'need_profile' : 'confirming'
  const updated = await updatePcSessionStatus(sessionId, nextStatus, user.id)
  if (!updated) {
    const error = new Error('扫码登录会话已结束，请刷新电脑端二维码')
    error.statusCode = 409
    error.code = 'pc_login_session_closed'
    throw error
  }
  if (updated.status === 'expired') {
    const error = new Error('扫码登录已过期，请刷新电脑端二维码')
    error.statusCode = 410
    error.code = 'pc_login_expired'
    throw error
  }
  return { ok: true, status: updated.status, nextAction, client: updated.client || null }
}

export async function confirmPcScanLogin(token, payload = {}) {
  const { user } = await requireUser(token)
  const sessionId = requirePcSessionId(payload)
  if (!user.phone) {
    await updatePcSessionStatus(sessionId, 'need_phone', user.id)
    const error = new Error('请先绑定手机号')
    error.statusCode = 409
    error.code = 'phone_required'
    throw error
  }
  if (!user.onboardingCompleted) {
    await updatePcSessionStatus(sessionId, 'need_profile', user.id)
    const error = new Error('请先完善资料')
    error.statusCode = 409
    error.code = 'profile_required'
    throw error
  }

  const exchangeCode = secureToken('pcx', 16)
  const updated = await updatePcSessionStatus(sessionId, 'confirmed', user.id, exchangeCode)
  if (!updated) {
    const error = new Error('扫码登录会话已结束，请刷新电脑端二维码')
    error.statusCode = 409
    error.code = 'pc_login_session_closed'
    throw error
  }
  if (updated.status === 'expired') {
    const error = new Error('扫码登录已过期，请刷新电脑端二维码')
    error.statusCode = 410
    error.code = 'pc_login_expired'
    throw error
  }
  return { ok: true }
}

export async function cancelPcScanLogin(token, payload = {}) {
  const { user } = await requireUser(token)
  const sessionId = requirePcSessionId(payload)
  const updated = await updatePcSessionStatus(sessionId, 'cancelled', user.id)
  if (!updated) {
    const error = new Error('扫码登录会话已结束')
    error.statusCode = 409
    error.code = 'pc_login_session_closed'
    throw error
  }
  return { ok: true, status: updated.status }
}

export async function exchangePcScanLogin(payload = {}, options = {}) {
  await enforceAuthRateLimit(
    `auth:pc-scan:exchange:ip:${authClientKey(options)}:10m`,
    80,
    600,
    '扫码登录请求过于频繁，请稍后再试',
  )
  const sessionId = String(payload.sessionId || payload.session_id || '').trim()
  const exchangeCode = String(payload.exchangeCode || payload.exchange_code || '').trim()
  const session = await queryJson(`
    update pc_login_sessions
    set exchange_code = null,
        updated_at = now()
    where session_id = ${sqlLiteral(sessionId)}
      and exchange_code = ${sqlLiteral(exchangeCode)}
      and status = 'confirmed'
      and expires_at > now()
      and user_id is not null
    returning json_build_object(
      'userId', user_id,
      'redirect', redirect
    );
  `)
  if (!session) {
    const error = new Error('扫码登录凭证无效或已过期')
    error.statusCode = 401
    error.code = 'pc_login_exchange_invalid'
    throw error
  }
  return {
    ...(await createUserSession(session.userId)),
    redirect: session.redirect || '/',
  }
}

export async function requireUser(token) {
  const user = await findActiveUserBySessionToken(token)

  if (!user) {
    const error = new Error('请先登录')
    error.statusCode = 401
    error.code = 'unauthorized'
    throw error
  }

  return { user }
}

function aiPermissionScopeConfig(scopeCode) {
  return aiPermissionCatalog.find((item) => item.scopeCode === scopeCode) || null
}

function normalizeAiAccessLevel(value, fallback = 'denied') {
  const level = String(value || '').trim()
  return aiAccessLevels.has(level) ? level : fallback
}

function clampAiAccessLevel(scopeCode, level) {
  const config = aiPermissionScopeConfig(scopeCode)
  if (!config) return 'denied'
  const normalized = normalizeAiAccessLevel(level, config.defaultAccessLevel)
  if (config.maxAccessLevel === 'read' && normalized === 'operate') return 'read'
  return normalized
}

function aiPermissionItem(config, stored = null) {
  const accessLevel = clampAiAccessLevel(config.scopeCode, stored?.accessLevel || config.defaultAccessLevel)
  return {
    ...config,
    accessLevel,
    updatedAt: stored?.updatedAt || null,
  }
}

function canAiAccessLevel(current, required = 'read') {
  const level = normalizeAiAccessLevel(current)
  if (required === 'operate') return level === 'operate'
  return level === 'read' || level === 'operate'
}

export function aiPermissionScopeCatalog() {
  return aiPermissionCatalog.map((item) => aiPermissionItem(item))
}

export async function getAccountAiPermissions(token) {
  const { user } = await requireUser(token)
  const rows = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'scopeCode', scope_code,
      'accessLevel', access_level,
      'updatedAt', updated_at
    ) order by scope_code), '[]'::json)
    from user_ai_permissions
    where user_id = ${sqlLiteral(user.id)}::uuid;
  `)
  const stored = new Map((Array.isArray(rows) ? rows : []).map((item) => [item.scopeCode, item]))
  return {
    items: aiPermissionCatalog.map((config) => aiPermissionItem(config, stored.get(config.scopeCode))),
    dangerousScopes: [
      '支付、调起微信支付或创建支付单',
      '创建订单、加入购物车或自动购买',
      'PC 扫码登录确认、验证码和账号登录',
      '注销账号、修改手机号、微信号、税号等账号资料',
      '下载发票或业务交付文件',
      '课程/活动核销、签到和验票',
      '确认专家服务完成、发起争议、提现、结算或退款',
      '员工工作台、后台管理和核销端任何操作',
    ],
  }
}

export async function updateAccountAiPermissions(token, payload = {}) {
  const { user } = await requireUser(token)
  const requested = Array.isArray(payload.items) ? payload.items : []
  const allowedScopes = new Set(aiPermissionCatalog.map((item) => item.scopeCode))
  const normalized = requested
    .map((item) => ({
      scopeCode: String(item.scopeCode || item.scope_code || item.code || '').trim(),
      accessLevel: String(item.accessLevel || item.access_level || item.level || '').trim(),
    }))
    .filter((item) => allowedScopes.has(item.scopeCode))

  for (const item of normalized) {
    const accessLevel = clampAiAccessLevel(item.scopeCode, item.accessLevel)
    await queryJson(`
      insert into user_ai_permissions (user_id, scope_code, access_level)
      values (${sqlLiteral(user.id)}::uuid, ${sqlLiteral(item.scopeCode)}, ${sqlLiteral(accessLevel)})
      on conflict (user_id, scope_code)
      do update set access_level = excluded.access_level, updated_at = now()
      returning json_build_object('ok', true);
    `)
  }

  await recordAiAssistAudit({
    userId: user.id,
    intent: 'updateAiPermissions',
    requestedScope: 'ai_permissions',
    decision: 'allowed',
    actionType: 'settings_update',
    metadata: {
      items: normalized.map((item) => ({
        scopeCode: item.scopeCode,
        accessLevel: clampAiAccessLevel(item.scopeCode, item.accessLevel),
      })),
    },
  })
  return getAccountAiPermissions(token)
}

export async function assertAccountAiPermission(token, scopeCode, requiredAccessLevel = 'read') {
  const { user } = await requireUser(token)
  const config = aiPermissionScopeConfig(scopeCode)
  if (!config) {
    const error = new Error('AI 权限范围不存在')
    error.statusCode = 400
    error.code = 'invalid_ai_permission_scope'
    throw error
  }
  const row = await queryJson(`
    select json_build_object(
      'scopeCode', scope_code,
      'accessLevel', access_level,
      'updatedAt', updated_at
    )
    from user_ai_permissions
    where user_id = ${sqlLiteral(user.id)}::uuid
      and scope_code = ${sqlLiteral(scopeCode)}
    limit 1;
  `)
  const item = aiPermissionItem(config, row)
  if (!canAiAccessLevel(item.accessLevel, requiredAccessLevel)) {
    const error = new Error(requiredAccessLevel === 'operate' ? '未授权 AI 执行该操作' : '未授权 AI 读取该信息')
    error.statusCode = 403
    error.code = 'ai_permission_denied'
    error.permission = item
    throw error
  }
  return { user, permission: item }
}

export async function recordAiAssistAudit({
  userId = null,
  intent = '',
  requestedScope = '',
  decision = 'allowed',
  riskCode = '',
  actionType = '',
  metadata = {},
} = {}) {
  const normalizedDecision = ['allowed', 'denied', 'blocked'].includes(decision) ? decision : 'allowed'
  if (!userId) return null
  return queryJson(`
    insert into ai_assist_audit_logs (
      user_id, intent, requested_scope, decision, risk_code, action_type, metadata
    )
    values (
      ${sqlLiteral(userId)}::uuid,
      ${sqlLiteral(String(intent || '').slice(0, 120))},
      ${sqlLiteral(String(requestedScope || '').slice(0, 120))},
      ${sqlLiteral(normalizedDecision)},
      ${sqlLiteral(String(riskCode || '').slice(0, 120))},
      ${sqlLiteral(String(actionType || '').slice(0, 120))},
      ${sqlLiteral(JSON.stringify(metadata || {}))}::jsonb
    )
    returning json_build_object('id', id, 'createdAt', created_at);
  `)
}

export async function loginStaffByPhone(phone, code, options = {}) {
  phone = normalizePhone(phone)
  assertValidPhone(phone)
  const cleanCode = String(code || '').trim()
  const clientKey = authClientKey(options)
  await enforceAuthRateLimits([
    {
      bucket: `auth:staff-login:phone:${normalizeRatePart(phone)}:15m`,
      limit: 10,
      windowSeconds: 900,
      message: '验证码尝试次数过多，请稍后再试',
    },
    {
      bucket: `auth:staff-login:ip:${clientKey}:15m`,
      limit: 60,
      windowSeconds: 900,
      message: '工作台登录请求过于频繁，请稍后再试',
    },
  ])
  await assertValidPhoneCode(phone, cleanCode)

  const account = await findActiveStaffAccountByPhone(phone)

  if (!account) {
    metric('dlbdi_login_attempts_total', { actor: 'staff', status: 'failed', reason: 'staff_phone_not_allowed' })
    const error = new Error('该手机号没有工作台权限')
    error.statusCode = 403
    error.code = 'staff_phone_not_allowed'
    throw error
  }

  const token = `staff_${randomUUID()}`
  await deletePhoneCode(phone, cleanCode)
  await createStaffSession(account.id, token)
  metric('dlbdi_login_attempts_total', { actor: 'staff', status: 'succeeded', reason: 'ok' })
  await auditStaff({
    actorAccount: account,
    action: 'staff.login.phone',
    targetType: 'staff_account',
    targetId: account.id,
    summary: `${account.displayName || account.phone} 使用手机号验证码登录`,
    metadata: { phone },
  })

  return {
    token,
    account: await publicStaffAccount(account),
  }
}

export async function getStaffMe(token) {
  const { account } = await requireAdmin(token, 'staff:me')
  return account
}

function staffRoleFallback(code) {
  return defaultStaffRoles.find((role) => role.code === code) || null
}

export function getBuiltInStaffRole(code) {
  const role = staffRoleFallback(code)
  return role ? { ...role, permissions: [...role.permissions] } : null
}

function staffRoleJson(alias = 'sr') {
  return `json_build_object(
    'code', ${alias}.code,
    'name', ${alias}.name,
    'description', ${alias}.description,
    'defaultEntry', ${alias}.default_entry,
    'status', ${alias}.status,
    'permissions', ${alias}.permissions,
    'roleFamily', coalesce(${alias}.role_family, 'operations'),
    'roleLevel', coalesce(${alias}.role_level, 50),
    'sortOrder', coalesce(${alias}.sort_order, 100),
    'isSystem', coalesce(${alias}.is_system, false),
    'staffCount', coalesce((
      select count(*)::int
      from staff_accounts sa
      where sa.role = ${alias}.code
        and sa.role <> 'expert'
    ), 0),
    'activeStaffCount', coalesce((
      select count(*)::int
      from staff_accounts sa
      where sa.role = ${alias}.code
        and sa.role <> 'expert'
        and sa.status = 'active'
    ), 0),
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function staffAccountJson(alias = 'sa', roleAlias = 'sr') {
  return `json_build_object(
    'id', ${alias}.id,
    'email', ${alias}.email,
    'phone', ${alias}.phone,
    'displayName', ${alias}.display_name,
    'role', ${alias}.role,
    'roleName', coalesce(${roleAlias}.name, ${alias}.role),
    'defaultEntry', coalesce(${roleAlias}.default_entry, '/staff'),
    'status', ${alias}.status,
    'positionTitle', ${alias}.position_title,
    'orgUnitId', ${alias}.org_unit_id,
    'positionId', ${alias}.position_id,
    'remark', ${alias}.remark,
    'permissions', coalesce(${roleAlias}.permissions, '{}'::text[]),
    'taskCount', coalesce((select count(*)::int from fulfillment_tasks ft where ft.assignee_account_id = ${alias}.id), 0),
    'consultingCount', coalesce((select count(*)::int from consulting_requests cr where cr.assigned_account_id = ${alias}.id), 0),
    'dataServiceCount', coalesce((select count(*)::int from data_service_requests dsr where dsr.assigned_account_id = ${alias}.id), 0),
    'checkinCount', coalesce((select count(*)::int from course_registrations cr where cr.checked_in_by_staff_id = ${alias}.id), 0)
      + coalesce((select count(*)::int from event_registrations er where er.checked_in_by_staff_id = ${alias}.id), 0),
    'lastLoginAt', ${alias}.last_login_at,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

async function getStaffRolePermissions(roleCode) {
  const fallback = staffRoleFallback(roleCode)?.permissions || rolePermissions[roleCode] || []
  try {
    const role = await queryJson(`
      select json_build_object('permissions', permissions)
      from staff_roles
      where code = ${sqlLiteral(roleCode)}
        and status = 'active'
      limit 1;
    `)
    if (role && Array.isArray(role.permissions)) return role.permissions
    return fallback
  } catch {
    return fallback
  }
}

async function getStaffEffectivePermissions(account) {
  const fallback = await getStaffRolePermissions(account?.role)
  if (!account?.id || fallback.includes('*')) return fallback
  try {
    const result = await queryJson(`
      with recursive account_ctx as (
        select
          sa.id as account_id,
          coalesce(primary_assignment.role_code, sa.role) as role_code,
          coalesce(primary_assignment.org_unit_id, sa.org_unit_id) as org_unit_id,
          coalesce(primary_assignment.position_id, sa.position_id) as position_id
        from staff_accounts sa
        left join lateral (
          select saa.org_unit_id, saa.position_id, saa.role_code
          from staff_account_assignments saa
          where saa.account_id = sa.id
            and saa.status = 'active'
          order by saa.is_primary desc, saa.created_at
          limit 1
        ) primary_assignment on true
        where sa.id = ${sqlLiteral(account.id)}::uuid
        limit 1
      ),
      recursive_org_chain as (
        select ou.id, ou.parent_id
        from staff_org_units ou
        join account_ctx ctx on ctx.org_unit_id = ou.id
        union all
        select parent.id, parent.parent_id
        from staff_org_units parent
        join recursive_org_chain child on child.parent_id = parent.id
      ),
      granted_permissions as (
        select unnest(coalesce(sr.permissions, '{}'::text[])) as permission
        from staff_roles sr
        join account_ctx ctx on ctx.role_code = sr.code
        where sr.status = 'active'
        union
        select spg.permission_code as permission
        from staff_permission_grants spg
        join recursive_org_chain org_chain on spg.subject_type = 'org_unit' and spg.subject_id = org_chain.id
        union
        select spg.permission_code as permission
        from staff_permission_grants spg
        join account_ctx ctx on spg.subject_type = 'position' and spg.subject_id = ctx.position_id
        union
        select spg.permission_code as permission
        from staff_permission_grants spg
        join account_ctx ctx on spg.subject_type = 'account' and spg.subject_id = ctx.account_id
      )
      select json_build_object(
        'permissions',
        coalesce(array_agg(distinct permission order by permission) filter (where permission <> ''), '{}'::text[])
      )
      from granted_permissions;
    `)
    if (Array.isArray(result?.permissions) && result.permissions.length) return result.permissions
    return fallback
  } catch {
    return fallback
  }
}

async function getStaffRoleOrFallback(code) {
  try {
    const role = await queryJson(`
      select ${staffRoleJson('sr')}
      from staff_roles sr
      where sr.code = ${sqlLiteral(code)}
      limit 1;
    `)
    if (role) return role
  } catch {}
  const fallback = staffRoleFallback(code)
  return fallback ? { ...fallback, staffCount: 0, activeStaffCount: 0, createdAt: null, updatedAt: null } : null
}

function normalizeStaffRole(code) {
  return String(code || '').trim()
}

function normalizeStaffStatus(status) {
  return status === 'disabled' ? 'disabled' : 'active'
}

async function assertStaffRoleExists(roleCode) {
  const role = await getStaffRoleOrFallback(roleCode)
  if (!role || role.status === 'disabled') {
    const error = new Error('员工岗位角色不存在或已停用')
    error.statusCode = 400
    error.code = 'invalid_staff_role'
    throw error
  }
  return role
}

async function assertCanDisableStaffAccount(accountId, nextStatus) {
  if (nextStatus !== 'disabled') return
  const account = await getAdminStaffAccountById(accountId)
  if (!account || account.role !== 'super_admin' || account.status !== 'active') return
  const activeSuperAdminCount = await queryJson(`
    select count(*)::int
    from staff_accounts
    where role = 'super_admin'
      and status = 'active'
      and id <> ${sqlLiteral(account.id)}::uuid;
  `)
  if (!activeSuperAdminCount) {
    const error = new Error('至少需要保留一个启用的超级管理员')
    error.statusCode = 409
    error.code = 'last_super_admin'
    throw error
  }
}

async function getAdminStaffAccountById(id) {
  return queryJson(`
    select ${staffAccountJson('sa', 'sr')}
    from staff_accounts sa
    left join staff_roles sr on sr.code = sa.role
    where sa.role <> 'expert'
      and ${isUuid(id) ? `sa.id = ${sqlLiteral(id)}::uuid` : `(sa.phone = ${sqlLiteral(id)} or sa.email = ${sqlLiteral(String(id || '').trim().toLowerCase())})`}
    limit 1;
  `)
}

export async function listAdminStaffAccounts(token, filters = {}) {
  await requireAdmin(token, 'staff:read')
  const search = String(filters.search || '').trim()
  const status = String(filters.status || '').trim()
  const role = String(filters.role || '').trim()
  return queryJson(`
    select coalesce(json_agg(account_json order by (account_json->>'updatedAt') desc), '[]'::json)
    from (
      select ${staffAccountJson('sa', 'sr')} as account_json
      from staff_accounts sa
      left join staff_roles sr on sr.code = sa.role
      where sa.role <> 'expert'
        and (${sqlLiteral(status)} = '' or sa.status = ${sqlLiteral(status)})
        and (${sqlLiteral(role)} = '' or sa.role = ${sqlLiteral(role)})
        and (${sqlLiteral(search)} = ''
          or sa.phone ilike '%' || ${sqlLiteral(search)} || '%'
          or sa.email ilike '%' || ${sqlLiteral(search)} || '%'
          or sa.display_name ilike '%' || ${sqlLiteral(search)} || '%'
          or sa.position_title ilike '%' || ${sqlLiteral(search)} || '%'
          or coalesce(sr.name, sa.role) ilike '%' || ${sqlLiteral(search)} || '%')
    ) t;
  `)
}

export async function getAdminStaffAccount(token, id) {
  await requireAdmin(token, 'staff:read')
  return getAdminStaffAccountById(id)
}

export async function createAdminStaffAccount(token, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'staff:write')
  const phone = normalizePhone(payload.phone)
  assertValidPhone(phone)
  const role = normalizeStaffRole(payload.role || payload.roleCode || payload.role_code)
  await assertStaffRoleExists(role)
  assertSuperAdminPhone(role, phone)
  const displayName = String(payload.displayName || payload.display_name || '').trim()
  if (!displayName) {
    const error = new Error('员工姓名不能为空')
    error.statusCode = 400
    error.code = 'invalid_staff_name'
    throw error
  }
  const email = String(payload.email || `${phone}@staff.dlbdi.local`).trim().toLowerCase()
  const positionTitle = String(payload.positionTitle || payload.position_title || '').trim()
  const remark = String(payload.remark || '').trim()
  const status = normalizeStaffStatus(payload.status)
  const created = await queryJson(`
    insert into staff_accounts (
      email, phone, display_name, role, status, position_title, remark, password_salt, password_hash
    )
    values (
      ${sqlLiteral(email)},
      ${sqlLiteral(phone)},
      ${sqlLiteral(displayName)},
      ${sqlLiteral(role)},
      ${sqlLiteral(status)},
      ${sqlLiteral(positionTitle)},
      ${sqlLiteral(remark)},
      '',
      ''
    )
    returning json_build_object('id', id);
  `)
  const item = await getAdminStaffAccountById(created.id)
  await auditStaff({
    actorAccount: account,
    actorEmail: actor,
    action: 'staff_account.create',
    targetType: 'staff_account',
    targetId: item.id,
    summary: `新增员工 ${item.displayName}（${item.phone}）`,
    metadata: item,
  })
  return item
}

export async function updateAdminStaffAccount(token, accountId, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'staff:write')
  const current = await getAdminStaffAccountById(accountId)
  if (!current) {
    const error = new Error('员工账号不存在')
    error.statusCode = 404
    error.code = 'staff_account_not_found'
    throw error
  }
  const phone = payload.phone !== undefined ? normalizePhone(payload.phone) : current.phone
  assertValidPhone(phone)
  const role = payload.role !== undefined || payload.roleCode !== undefined || payload.role_code !== undefined
    ? normalizeStaffRole(payload.role || payload.roleCode || payload.role_code)
    : current.role
  await assertStaffRoleExists(role)
  assertSuperAdminPhone(role, phone)
  const displayName = payload.displayName !== undefined || payload.display_name !== undefined
    ? String(payload.displayName || payload.display_name || '').trim()
    : current.displayName
  if (!displayName) {
    const error = new Error('员工姓名不能为空')
    error.statusCode = 400
    error.code = 'invalid_staff_name'
    throw error
  }
  const email = payload.email !== undefined ? String(payload.email || '').trim().toLowerCase() : current.email
  const status = payload.status !== undefined ? normalizeStaffStatus(payload.status) : current.status
  await assertCanDisableStaffAccount(current.id, status)
  const updated = await queryJson(`
    update staff_accounts
    set email = ${sqlLiteral(email || `${phone}@staff.dlbdi.local`)},
        phone = ${sqlLiteral(phone)},
        display_name = ${sqlLiteral(displayName)},
        role = ${sqlLiteral(role)},
        status = ${sqlLiteral(status)},
        position_title = ${sqlLiteral(payload.positionTitle ?? payload.position_title ?? current.positionTitle ?? '')},
        remark = ${sqlLiteral(payload.remark ?? current.remark ?? '')},
        updated_at = now()
    where id = ${sqlLiteral(current.id)}::uuid
    returning json_build_object('id', id);
  `)
  const item = await getAdminStaffAccountById(updated.id)
  await auditStaff({
    actorAccount: account,
    actorEmail: actor,
    action: 'staff_account.update',
    targetType: 'staff_account',
    targetId: item.id,
    summary: `更新员工 ${item.displayName}（${item.phone}）`,
    metadata: { before: current, after: item },
  })
  return item
}

export async function setAdminStaffAccountStatus(token, accountId, status) {
  return updateAdminStaffAccount(token, accountId, { status })
}

export async function listAdminStaffRoles(token) {
  await requireAdmin(token, 'role:read')
  return queryJson(`
    select coalesce(json_agg(${staffRoleJson('sr')} order by case when sr.code = 'super_admin' then 0 else 1 end, sr.sort_order, sr.created_at), '[]'::json)
    from staff_roles sr
    where sr.code <> 'expert';
  `)
}

export async function getAdminStaffRole(token, code) {
  await requireAdmin(token, 'role:read')
  return getStaffRoleOrFallback(code)
}

export async function listAdminStaffRoleAccounts(token, code) {
  await requireAdmin(token, 'role:read')
  const roleCode = normalizeStaffRole(code)
  return {
    items: await queryJson(`
      select coalesce(json_agg(account_json order by account_json->>'displayName'), '[]'::json)
      from (
        select ${staffAccountJson('sa', 'sr')} as account_json
        from staff_accounts sa
        left join staff_roles sr on sr.code = sa.role
        where sa.role <> 'expert'
          and (
            sa.role = ${sqlLiteral(roleCode)}
            or exists (
              select 1
              from staff_account_assignments saa
              where saa.account_id = sa.id
                and saa.role_code = ${sqlLiteral(roleCode)}
                and saa.status = 'active'
            )
          )
      ) t;
    `),
  }
}

export async function updateAdminStaffRole(token, code, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'role:write')
  const current = await getStaffRoleOrFallback(code)
  if (!current || code === 'expert') {
    const error = new Error('岗位角色不存在')
    error.statusCode = 404
    error.code = 'staff_role_not_found'
    throw error
  }
  if (code === 'super_admin') {
    const requested = Array.isArray(payload.permissions) ? payload.permissions : current.permissions
    if (!requested.includes('*')) {
      const error = new Error('超级管理员必须保留全部权限')
      error.statusCode = 400
      error.code = 'super_admin_requires_all_permissions'
      throw error
    }
    if (payload.status === 'disabled') {
      const error = new Error('超级管理员岗位不能停用')
      error.statusCode = 400
      error.code = 'super_admin_role_required'
      throw error
    }
  }
  const allowedCodes = new Set(permissionCatalog.map((item) => item.code))
  const requestedPermissions = Array.isArray(payload.permissions)
    ? Array.from(new Set(payload.permissions.map(String).filter((item) => allowedCodes.has(item))))
    : current.permissions
  const updated = await queryJson(`
    insert into staff_roles (code, name, description, default_entry, status, permissions)
    values (
      ${sqlLiteral(code)},
      ${sqlLiteral(payload.name ?? current.name)},
      ${sqlLiteral(payload.description ?? current.description ?? '')},
      ${sqlLiteral(payload.defaultEntry ?? payload.default_entry ?? current.defaultEntry ?? '/staff')},
      ${sqlLiteral(normalizeStaffStatus(payload.status ?? current.status))},
      ${sqlTextArray(requestedPermissions)}
    )
    on conflict (code) do update set
      name = excluded.name,
      description = excluded.description,
      default_entry = excluded.default_entry,
      status = excluded.status,
      permissions = excluded.permissions,
      updated_at = now()
    returning ${staffRoleJson('staff_roles')};
  `)
  await auditStaff({
    actorAccount: account,
    actorEmail: actor,
    action: 'staff_role.update',
    targetType: 'staff_role',
    targetId: code,
    summary: `更新岗位角色 ${updated.name}`,
    metadata: { before: current, after: updated },
  })
  return updated
}

export async function getAdminPermissionCatalog(token) {
  await requireAdmin(token, 'role:read')
  return { items: enrichedPermissionCatalog() }
}

function allowedPermissionCodes() {
  return new Set(permissionCatalog.map((item) => item.code))
}

function normalizePermissionCodes(value) {
  const allowed = allowedPermissionCodes()
  const items = Array.isArray(value) ? value : []
  return Array.from(new Set(items.map(String).filter((item) => allowed.has(item))))
}

function normalizeOrgStatus(status) {
  return status === 'disabled' ? 'disabled' : 'active'
}

function normalizeCode(value) {
  return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_:-]+/g, '_').replace(/^_+|_+$/g, '')
}

async function setPermissionGrants(subjectType, subjectId, permissions = []) {
  if (!isUuid(subjectId)) {
    const error = new Error('授权对象无效')
    error.statusCode = 400
    error.code = 'invalid_permission_subject'
    throw error
  }
  const codes = normalizePermissionCodes(permissions)
  await executeSql(`
    delete from staff_permission_grants
    where subject_type = ${sqlLiteral(subjectType)}
      and subject_id = ${sqlLiteral(subjectId)}::uuid;

    ${codes.length ? `
      insert into staff_permission_grants (subject_type, subject_id, permission_code)
      select ${sqlLiteral(subjectType)}, ${sqlLiteral(subjectId)}::uuid, permission_code
      from unnest(${sqlTextArray(codes)}) as permission_code
      on conflict do nothing;
    ` : ''}
  `)
  return codes
}

export async function listAdminOrgTree(token) {
  await requireAdmin(token, 'org:read')
  const units = await queryJson(`
    select coalesce(json_agg(unit_json order by (unit_json->>'path'), (unit_json->>'sortOrder')::int), '[]'::json)
    from (
      select json_build_object(
        'id', ou.id,
        'parentId', ou.parent_id,
        'code', ou.code,
        'name', ou.name,
        'unitType', ou.unit_type,
        'path', ou.path,
        'depth', ou.depth,
        'sortOrder', ou.sort_order,
        'status', ou.status,
        'managerAccountId', ou.manager_account_id,
        'description', ou.description,
        'permissions', coalesce((
          select json_agg(spg.permission_code order by spg.permission_code)
          from staff_permission_grants spg
          where spg.subject_type = 'org_unit'
            and spg.subject_id = ou.id
        ), '[]'::json),
        'staffCount', coalesce((
          select count(*)::int
          from staff_account_assignments saa
          where saa.org_unit_id = ou.id
            and saa.status = 'active'
        ), 0),
        'createdAt', ou.created_at,
        'updatedAt', ou.updated_at
      ) as unit_json
      from staff_org_units ou
    ) t;
  `) || []
  const positions = await queryJson(`
    select coalesce(json_agg(position_json order by (position_json->>'sortOrder')::int, position_json->>'name'), '[]'::json)
    from (
      select json_build_object(
        'id', sp.id,
        'orgUnitId', sp.org_unit_id,
        'parentPositionId', sp.parent_position_id,
        'code', sp.code,
        'name', sp.name,
        'roleCode', sp.role_code,
        'roleName', coalesce(sr.name, sp.role_code),
        'positionLevel', sp.position_level,
        'sortOrder', sp.sort_order,
        'status', sp.status,
        'description', sp.description,
        'permissions', coalesce((
          select json_agg(spg.permission_code order by spg.permission_code)
          from staff_permission_grants spg
          where spg.subject_type = 'position'
            and spg.subject_id = sp.id
        ), '[]'::json),
        'staffCount', coalesce((
          select count(*)::int
          from staff_account_assignments saa
          where saa.position_id = sp.id
            and saa.status = 'active'
        ), 0),
        'createdAt', sp.created_at,
        'updatedAt', sp.updated_at
      ) as position_json
      from staff_positions sp
      left join staff_roles sr on sr.code = sp.role_code
    ) t;
  `) || []
  const assignments = await listStaffAssignments()
  return { units, positions, assignments }
}

export async function createAdminOrgUnit(token, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'org:write')
  const code = normalizeCode(payload.code)
  const name = String(payload.name || '').trim()
  if (!code || !name) {
    const error = new Error('组织编码和名称不能为空')
    error.statusCode = 400
    error.code = 'invalid_org_unit'
    throw error
  }
  const parentId = isUuid(payload.parentId || payload.parent_id) ? String(payload.parentId || payload.parent_id) : null
  const unit = await queryJson(`
    with parent as (
      select id, path, depth
      from staff_org_units
      where ${parentId ? `id = ${sqlLiteral(parentId)}::uuid` : 'false'}
      limit 1
    )
    insert into staff_org_units (
      parent_id, code, name, unit_type, path, depth, sort_order, status, description
    )
    values (
      ${parentId ? `${sqlLiteral(parentId)}::uuid` : 'null'},
      ${sqlLiteral(code)},
      ${sqlLiteral(name)},
      ${sqlLiteral(payload.unitType || payload.unit_type || 'department')},
      coalesce((select path || '/' || ${sqlLiteral(code)} from parent), '/' || ${sqlLiteral(code)}),
      coalesce((select depth + 1 from parent), 0),
      ${Number(payload.sortOrder ?? payload.sort_order ?? 100) || 100},
      ${sqlLiteral(normalizeOrgStatus(payload.status))},
      ${sqlLiteral(payload.description || '')}
    )
    on conflict (code) do update set
      parent_id = excluded.parent_id,
      name = excluded.name,
      unit_type = excluded.unit_type,
      path = excluded.path,
      depth = excluded.depth,
      sort_order = excluded.sort_order,
      status = excluded.status,
      description = excluded.description,
      updated_at = now()
    returning json_build_object('id', id, 'code', code, 'name', name);
  `)
  await auditStaff({ actorAccount: account, actorEmail: actor, action: 'org_unit.upsert', targetType: 'staff_org_unit', targetId: unit.id, summary: `维护组织 ${unit.name}`, metadata: payload })
  return listAdminOrgTree(token)
}

export async function updateAdminOrgUnit(token, id, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'org:write')
  const current = await queryJson(`
    select json_build_object('id', id, 'code', code)
    from staff_org_units
    where id = ${sqlLiteral(id)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('组织不存在')
    error.statusCode = 404
    error.code = 'org_unit_not_found'
    throw error
  }
  const parentId = isUuid(payload.parentId || payload.parent_id) ? String(payload.parentId || payload.parent_id) : null
  await executeSql(`
    with parent as (
      select id, path, depth
      from staff_org_units
      where ${parentId ? `id = ${sqlLiteral(parentId)}::uuid` : 'false'}
      limit 1
    )
    update staff_org_units
    set
      parent_id = ${payload.parentId !== undefined || payload.parent_id !== undefined ? (parentId ? `${sqlLiteral(parentId)}::uuid` : 'null') : 'parent_id'},
      name = coalesce(${sqlLiteral(payload.name)}, name),
      unit_type = coalesce(${sqlLiteral(payload.unitType ?? payload.unit_type)}, unit_type),
      path = case
        when ${payload.parentId !== undefined || payload.parent_id !== undefined ? 'true' : 'false'} then coalesce((select path || '/' || code from parent), '/' || code)
        else path
      end,
      depth = case
        when ${payload.parentId !== undefined || payload.parent_id !== undefined ? 'true' : 'false'} then coalesce((select depth + 1 from parent), 0)
        else depth
      end,
      sort_order = coalesce(${Number.isFinite(Number(payload.sortOrder ?? payload.sort_order)) ? Number(payload.sortOrder ?? payload.sort_order) : 'null'}, sort_order),
      status = coalesce(${sqlLiteral(payload.status ? normalizeOrgStatus(payload.status) : null)}, status),
      description = coalesce(${sqlLiteral(payload.description)}, description),
      updated_at = now()
    where id = ${sqlLiteral(id)}::uuid;
  `)
  await auditStaff({ actorAccount: account, actorEmail: actor, action: 'org_unit.update', targetType: 'staff_org_unit', targetId: id, summary: `更新组织 ${current.code}`, metadata: payload })
  return listAdminOrgTree(token)
}

export async function updateAdminOrgUnitPermissions(token, id, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'org:write')
  const permissions = await setPermissionGrants('org_unit', id, payload.permissions)
  await auditStaff({ actorAccount: account, actorEmail: actor, action: 'org_unit.permissions', targetType: 'staff_org_unit', targetId: id, summary: '更新组织继承权限', metadata: { permissions } })
  return listAdminOrgTree(token)
}

export async function createAdminPosition(token, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'org:write')
  const code = normalizeCode(payload.code)
  const name = String(payload.name || '').trim()
  const orgUnitId = String(payload.orgUnitId || payload.org_unit_id || '').trim()
  const roleCode = normalizeStaffRole(payload.roleCode || payload.role_code || payload.role)
  if (!code || !name || !isUuid(orgUnitId) || !roleCode) {
    const error = new Error('岗位编码、名称、组织和角色不能为空')
    error.statusCode = 400
    error.code = 'invalid_position'
    throw error
  }
  await assertStaffRoleExists(roleCode)
  const parentPositionId = isUuid(payload.parentPositionId || payload.parent_position_id) ? String(payload.parentPositionId || payload.parent_position_id) : null
  const position = await queryJson(`
    insert into staff_positions (
      org_unit_id, parent_position_id, code, name, role_code, position_level, sort_order, status, description
    )
    values (
      ${sqlLiteral(orgUnitId)}::uuid,
      ${parentPositionId ? `${sqlLiteral(parentPositionId)}::uuid` : 'null'},
      ${sqlLiteral(code)},
      ${sqlLiteral(name)},
      ${sqlLiteral(roleCode)},
      ${Number(payload.positionLevel ?? payload.position_level ?? 50) || 50},
      ${Number(payload.sortOrder ?? payload.sort_order ?? 100) || 100},
      ${sqlLiteral(normalizeOrgStatus(payload.status))},
      ${sqlLiteral(payload.description || '')}
    )
    on conflict (code) do update set
      org_unit_id = excluded.org_unit_id,
      parent_position_id = excluded.parent_position_id,
      name = excluded.name,
      role_code = excluded.role_code,
      position_level = excluded.position_level,
      sort_order = excluded.sort_order,
      status = excluded.status,
      description = excluded.description,
      updated_at = now()
    returning json_build_object('id', id, 'code', code, 'name', name);
  `)
  await auditStaff({ actorAccount: account, actorEmail: actor, action: 'position.upsert', targetType: 'staff_position', targetId: position.id, summary: `维护岗位 ${position.name}`, metadata: payload })
  return listAdminOrgTree(token)
}

export async function updateAdminPosition(token, id, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'org:write')
  const roleCode = payload.roleCode !== undefined || payload.role_code !== undefined || payload.role !== undefined
    ? normalizeStaffRole(payload.roleCode || payload.role_code || payload.role)
    : null
  if (roleCode) await assertStaffRoleExists(roleCode)
  await executeSql(`
    update staff_positions
    set
      org_unit_id = coalesce(${isUuid(payload.orgUnitId || payload.org_unit_id) ? `${sqlLiteral(payload.orgUnitId || payload.org_unit_id)}::uuid` : 'null'}, org_unit_id),
      parent_position_id = ${payload.parentPositionId !== undefined || payload.parent_position_id !== undefined
        ? (isUuid(payload.parentPositionId || payload.parent_position_id) ? `${sqlLiteral(payload.parentPositionId || payload.parent_position_id)}::uuid` : 'null')
        : 'parent_position_id'},
      name = coalesce(${sqlLiteral(payload.name)}, name),
      role_code = coalesce(${sqlLiteral(roleCode)}, role_code),
      position_level = coalesce(${Number.isFinite(Number(payload.positionLevel ?? payload.position_level)) ? Number(payload.positionLevel ?? payload.position_level) : 'null'}, position_level),
      sort_order = coalesce(${Number.isFinite(Number(payload.sortOrder ?? payload.sort_order)) ? Number(payload.sortOrder ?? payload.sort_order) : 'null'}, sort_order),
      status = coalesce(${sqlLiteral(payload.status ? normalizeOrgStatus(payload.status) : null)}, status),
      description = coalesce(${sqlLiteral(payload.description)}, description),
      updated_at = now()
    where id = ${sqlLiteral(id)}::uuid;
  `)
  await auditStaff({ actorAccount: account, actorEmail: actor, action: 'position.update', targetType: 'staff_position', targetId: id, summary: '更新岗位', metadata: payload })
  return listAdminOrgTree(token)
}

export async function updateAdminPositionPermissions(token, id, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'org:write')
  const permissions = await setPermissionGrants('position', id, payload.permissions)
  await auditStaff({ actorAccount: account, actorEmail: actor, action: 'position.permissions', targetType: 'staff_position', targetId: id, summary: '更新岗位权限', metadata: { permissions } })
  return listAdminOrgTree(token)
}

async function listStaffAssignments() {
  return (await queryJson(`
    select coalesce(json_agg(assignment_json order by assignment_json->>'accountName'), '[]'::json)
    from (
      select json_build_object(
        'id', saa.id,
        'accountId', sa.id,
        'accountName', sa.display_name,
        'accountPhone', sa.phone,
        'orgUnitId', saa.org_unit_id,
        'orgUnitName', ou.name,
        'positionId', saa.position_id,
        'positionName', sp.name,
        'roleCode', saa.role_code,
        'roleName', coalesce(sr.name, saa.role_code),
        'isPrimary', saa.is_primary,
        'status', saa.status,
        'createdAt', saa.created_at,
        'updatedAt', saa.updated_at
      ) as assignment_json
      from staff_account_assignments saa
      join staff_accounts sa on sa.id = saa.account_id
      left join staff_org_units ou on ou.id = saa.org_unit_id
      left join staff_positions sp on sp.id = saa.position_id
      left join staff_roles sr on sr.code = saa.role_code
      where sa.role <> 'expert'
    ) t;
  `)) || []
}

export async function listAdminStaffAssignments(token) {
  await requireAdmin(token, 'staff:read')
  return { items: await listStaffAssignments() }
}

export async function createAdminStaffAssignment(token, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'staff:write')
  const accountId = String(payload.accountId || payload.account_id || '').trim()
  const orgUnitId = String(payload.orgUnitId || payload.org_unit_id || '').trim()
  const positionId = isUuid(payload.positionId || payload.position_id) ? String(payload.positionId || payload.position_id) : null
  const roleCode = normalizeStaffRole(payload.roleCode || payload.role_code || payload.role)
  if (!isUuid(accountId) || !isUuid(orgUnitId) || !roleCode) {
    const error = new Error('员工、组织和角色不能为空')
    error.statusCode = 400
    error.code = 'invalid_staff_assignment'
    throw error
  }
  await assertStaffRoleExists(roleCode)
  const isPrimary = payload.isPrimary !== false && payload.is_primary !== false
  await executeSql(`
    ${isPrimary ? `
      update staff_account_assignments
      set is_primary = false,
          updated_at = now()
      where account_id = ${sqlLiteral(accountId)}::uuid;
    ` : ''}
    insert into staff_account_assignments (account_id, org_unit_id, position_id, role_code, is_primary, status)
    values (
      ${sqlLiteral(accountId)}::uuid,
      ${sqlLiteral(orgUnitId)}::uuid,
      ${positionId ? `${sqlLiteral(positionId)}::uuid` : 'null'},
      ${sqlLiteral(roleCode)},
      ${isPrimary ? 'true' : 'false'},
      ${sqlLiteral(normalizeOrgStatus(payload.status))}
    )
    on conflict do nothing;

    ${isPrimary ? `
      update staff_accounts
      set org_unit_id = ${sqlLiteral(orgUnitId)}::uuid,
          position_id = ${positionId ? `${sqlLiteral(positionId)}::uuid` : 'null'},
          role = ${sqlLiteral(roleCode)},
          updated_at = now()
      where id = ${sqlLiteral(accountId)}::uuid;
    ` : ''}
  `)
  await auditStaff({ actorAccount: account, actorEmail: actor, action: 'staff_assignment.upsert', targetType: 'staff_account', targetId: accountId, summary: '设置员工组织岗位', metadata: payload })
  return listAdminStaffAssignments(token)
}

export async function updateAdminStaffAssignment(token, accountId, assignmentId, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'staff:write')
  if (!isUuid(accountId) || !isUuid(assignmentId)) {
    const error = new Error('员工归属无效')
    error.statusCode = 400
    error.code = 'invalid_staff_assignment'
    throw error
  }
  const current = await queryJson(`
    select json_build_object(
      'id', id,
      'accountId', account_id,
      'orgUnitId', org_unit_id,
      'positionId', position_id,
      'roleCode', role_code,
      'isPrimary', is_primary,
      'status', status
    )
    from staff_account_assignments
    where id = ${sqlLiteral(assignmentId)}::uuid
      and account_id = ${sqlLiteral(accountId)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('员工归属不存在')
    error.statusCode = 404
    error.code = 'staff_assignment_not_found'
    throw error
  }
  const orgUnitId = payload.orgUnitId !== undefined || payload.org_unit_id !== undefined
    ? String(payload.orgUnitId || payload.org_unit_id || '').trim()
    : current.orgUnitId
  const positionId = payload.positionId !== undefined || payload.position_id !== undefined
    ? (isUuid(payload.positionId || payload.position_id) ? String(payload.positionId || payload.position_id) : null)
    : current.positionId
  const roleCode = payload.roleCode !== undefined || payload.role_code !== undefined || payload.role !== undefined
    ? normalizeStaffRole(payload.roleCode || payload.role_code || payload.role)
    : current.roleCode
  if (!isUuid(orgUnitId) || !roleCode) {
    const error = new Error('员工组织和角色不能为空')
    error.statusCode = 400
    error.code = 'invalid_staff_assignment'
    throw error
  }
  await assertStaffRoleExists(roleCode)
  const isPrimary = payload.isPrimary !== undefined || payload.is_primary !== undefined
    ? payload.isPrimary !== false && payload.is_primary !== false
    : current.isPrimary
  const status = normalizeOrgStatus(payload.status ?? current.status)
  await executeSql(`
    ${isPrimary ? `
      update staff_account_assignments
      set is_primary = false,
          updated_at = now()
      where account_id = ${sqlLiteral(accountId)}::uuid
        and id <> ${sqlLiteral(assignmentId)}::uuid;
    ` : ''}
    update staff_account_assignments
    set org_unit_id = ${sqlLiteral(orgUnitId)}::uuid,
        position_id = ${positionId ? `${sqlLiteral(positionId)}::uuid` : 'null'},
        role_code = ${sqlLiteral(roleCode)},
        is_primary = ${isPrimary ? 'true' : 'false'},
        status = ${sqlLiteral(status)},
        updated_at = now()
    where id = ${sqlLiteral(assignmentId)}::uuid
      and account_id = ${sqlLiteral(accountId)}::uuid;

    ${isPrimary ? `
      update staff_accounts
      set org_unit_id = ${sqlLiteral(orgUnitId)}::uuid,
          position_id = ${positionId ? `${sqlLiteral(positionId)}::uuid` : 'null'},
          role = ${sqlLiteral(roleCode)},
          updated_at = now()
      where id = ${sqlLiteral(accountId)}::uuid;
    ` : ''}
  `)
  await auditStaff({
    actorAccount: account,
    actorEmail: actor,
    action: 'staff_assignment.update',
    targetType: 'staff_account',
    targetId: accountId,
    summary: '更新员工组织岗位',
    metadata: { before: current, after: { ...payload, isPrimary, status } },
  })
  return listAdminStaffAssignments(token)
}

export async function deleteAdminStaffAssignment(token, accountId, assignmentId) {
  const { actor, account } = await requireAdmin(token, 'staff:write')
  if (!isUuid(accountId) || !isUuid(assignmentId)) {
    const error = new Error('员工归属无效')
    error.statusCode = 400
    error.code = 'invalid_staff_assignment'
    throw error
  }
  const current = await queryJson(`
    select json_build_object('id', id, 'isPrimary', is_primary, 'status', status)
    from staff_account_assignments
    where id = ${sqlLiteral(assignmentId)}::uuid
      and account_id = ${sqlLiteral(accountId)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('员工归属不存在')
    error.statusCode = 404
    error.code = 'staff_assignment_not_found'
    throw error
  }
  if (current.isPrimary) {
    const error = new Error('主岗位不能直接移除，请先设置新的主岗位')
    error.statusCode = 409
    error.code = 'primary_assignment_required'
    throw error
  }
  await executeSql(`
    update staff_account_assignments
    set status = 'disabled',
        updated_at = now()
    where id = ${sqlLiteral(assignmentId)}::uuid
      and account_id = ${sqlLiteral(accountId)}::uuid;
  `)
  await auditStaff({
    actorAccount: account,
    actorEmail: actor,
    action: 'staff_assignment.disable',
    targetType: 'staff_account',
    targetId: accountId,
    summary: '停用员工兼岗归属',
    metadata: current,
  })
  return listAdminStaffAssignments(token)
}

export async function getAdminStaffEffectivePermissions(token, accountId) {
  await requireAdmin(token, 'staff:read')
  if (!isUuid(accountId)) {
    const error = new Error('员工账号无效')
    error.statusCode = 400
    error.code = 'invalid_staff_account'
    throw error
  }
  const result = await queryJson(`
    with recursive account_ctx as (
      select
        sa.id as account_id,
        coalesce(primary_assignment.role_code, sa.role) as role_code,
        coalesce(primary_assignment.org_unit_id, sa.org_unit_id) as org_unit_id,
        coalesce(primary_assignment.position_id, sa.position_id) as position_id
      from staff_accounts sa
      left join lateral (
        select saa.org_unit_id, saa.position_id, saa.role_code
        from staff_account_assignments saa
        where saa.account_id = sa.id
          and saa.status = 'active'
        order by saa.is_primary desc, saa.created_at
        limit 1
      ) primary_assignment on true
      where sa.id = ${sqlLiteral(accountId)}::uuid
        and sa.role <> 'expert'
      limit 1
    ),
    org_chain as (
      select ou.id, ou.parent_id
      from staff_org_units ou
      join account_ctx ctx on ctx.org_unit_id = ou.id
      union all
      select parent.id, parent.parent_id
      from staff_org_units parent
      join org_chain child on child.parent_id = parent.id
    ),
    role_permissions as (
      select unnest(coalesce(sr.permissions, '{}'::text[])) as permission
      from staff_roles sr
      join account_ctx ctx on ctx.role_code = sr.code
      where sr.status = 'active'
    ),
    org_permissions as (
      select spg.permission_code as permission
      from staff_permission_grants spg
      join org_chain oc on spg.subject_type = 'org_unit' and spg.subject_id = oc.id
    ),
    position_permissions as (
      select spg.permission_code as permission
      from staff_permission_grants spg
      join account_ctx ctx on spg.subject_type = 'position' and spg.subject_id = ctx.position_id
    ),
    direct_permissions as (
      select spg.permission_code as permission
      from staff_permission_grants spg
      join account_ctx ctx on spg.subject_type = 'account' and spg.subject_id = ctx.account_id
    ),
    effective_permissions as (
      select permission from role_permissions
      union
      select permission from org_permissions
      union
      select permission from position_permissions
      union
      select permission from direct_permissions
    )
    select json_build_object(
      'accountId', ctx.account_id,
      'roleCode', ctx.role_code,
      'orgUnitId', ctx.org_unit_id,
      'positionId', ctx.position_id,
      'rolePermissions', coalesce((select array_agg(distinct permission order by permission) from role_permissions where permission <> ''), '{}'::text[]),
      'orgPermissions', coalesce((select array_agg(distinct permission order by permission) from org_permissions where permission <> ''), '{}'::text[]),
      'positionPermissions', coalesce((select array_agg(distinct permission order by permission) from position_permissions where permission <> ''), '{}'::text[]),
      'directPermissions', coalesce((select array_agg(distinct permission order by permission) from direct_permissions where permission <> ''), '{}'::text[]),
      'effectivePermissions', coalesce((select array_agg(distinct permission order by permission) from effective_permissions where permission <> ''), '{}'::text[])
    )
    from account_ctx ctx
    limit 1;
  `)
  if (!result) {
    const error = new Error('员工账号不存在')
    error.statusCode = 404
    error.code = 'staff_account_not_found'
    throw error
  }
  return result
}

export async function updateAdminStaffDirectPermissions(token, accountId, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'staff:write')
  if (!isUuid(accountId)) {
    const error = new Error('员工账号无效')
    error.statusCode = 400
    error.code = 'invalid_staff_account'
    throw error
  }
  const staffAccount = await getAdminStaffAccountById(accountId)
  if (!staffAccount) {
    const error = new Error('员工账号不存在')
    error.statusCode = 404
    error.code = 'staff_account_not_found'
    throw error
  }
  const permissions = await setPermissionGrants('account', staffAccount.id, payload.permissions)
  await auditStaff({
    actorAccount: account,
    actorEmail: actor,
    action: 'staff_account.direct_permissions',
    targetType: 'staff_account',
    targetId: staffAccount.id,
    summary: `更新员工 ${staffAccount.displayName} 的特殊授权`,
    metadata: { permissions },
  })
  return getAdminStaffEffectivePermissions(token, staffAccount.id)
}

export async function listAdminStaffAuditLogs(token, filters = {}) {
  await requireAdmin(token, 'audit:read')
  const search = String(filters.search || '').trim()
  const action = String(filters.action || '').trim()
  const source = ['staff', 'admin'].includes(filters.source) ? filters.source : ''
  const targetType = String(filters.targetType || filters.target_type || '').trim()
  const dateFrom = String(filters.dateFrom || filters.date_from || '').trim()
  const dateTo = String(filters.dateTo || filters.date_to || '').trim()
  const staffDateFilter = `${dateFrom ? `and sal.created_at >= ${sqlLiteral(dateFrom)}::timestamptz` : ''}
          ${dateTo ? `and sal.created_at <= ${sqlLiteral(dateTo)}::timestamptz` : ''}`
  const adminDateFilter = `${dateFrom ? `and aal.created_at >= ${sqlLiteral(dateFrom)}::timestamptz` : ''}
          ${dateTo ? `and aal.created_at <= ${sqlLiteral(dateTo)}::timestamptz` : ''}`
  return queryJson(`
    select coalesce(json_agg(log_json order by (log_json->>'createdAt') desc), '[]'::json)
    from (
      select log_json
      from (
        select
          sal.created_at,
          json_build_object(
            'id', sal.id,
            'source', 'staff',
            'actorAccountId', sal.actor_account_id,
            'actorEmail', sal.actor_email,
            'actorName', coalesce(actor.display_name, sal.actor_email),
            'action', sal.action,
            'targetType', sal.target_type,
            'targetId', sal.target_id,
            'summary', sal.summary,
            'metadata', sal.metadata,
            'createdAt', sal.created_at
          ) as log_json
        from staff_audit_logs sal
        left join staff_accounts actor on actor.id = sal.actor_account_id
        where (${sqlLiteral(action)} = '' or sal.action = ${sqlLiteral(action)})
          and (${sqlLiteral(source)} = '' or ${sqlLiteral(source)} = 'staff')
          and (${sqlLiteral(targetType)} = '' or sal.target_type = ${sqlLiteral(targetType)})
          ${staffDateFilter}
          and (${sqlLiteral(search)} = ''
            or sal.actor_email ilike '%' || ${sqlLiteral(search)} || '%'
            or coalesce(actor.display_name, '') ilike '%' || ${sqlLiteral(search)} || '%'
            or sal.action ilike '%' || ${sqlLiteral(search)} || '%'
            or sal.summary ilike '%' || ${sqlLiteral(search)} || '%'
            or sal.target_id ilike '%' || ${sqlLiteral(search)} || '%')
        union all
        select
          aal.created_at,
          json_build_object(
            'id', aal.id,
            'source', 'admin',
            'actorAccountId', null,
            'actorEmail', aal.actor,
            'actorName', aal.actor,
            'action', aal.action,
            'targetType', aal.resource,
            'targetId', coalesce(aal.resource_id, ''),
            'summary', aal.action || ' · ' || aal.resource,
            'metadata', aal.detail,
            'createdAt', aal.created_at
          ) as log_json
        from admin_audit_logs aal
        where (${sqlLiteral(action)} = '' or aal.action = ${sqlLiteral(action)})
          and (${sqlLiteral(source)} = '' or ${sqlLiteral(source)} = 'admin')
          and (${sqlLiteral(targetType)} = '' or aal.resource = ${sqlLiteral(targetType)})
          ${adminDateFilter}
          and (${sqlLiteral(search)} = ''
            or aal.actor ilike '%' || ${sqlLiteral(search)} || '%'
            or aal.action ilike '%' || ${sqlLiteral(search)} || '%'
            or aal.resource ilike '%' || ${sqlLiteral(search)} || '%'
            or coalesce(aal.resource_id, '') ilike '%' || ${sqlLiteral(search)} || '%')
      ) merged
      order by created_at desc
      limit 200
    ) t;
  `)
}

export async function searchAdminBusinessNumber(token, rawQuery) {
  await requireAdmin(token, 'staff:me')
  const query = String(rawQuery || '').trim().toUpperCase().replace(/\s+/g, '')
  if (!query) return []

  const prefix = query.match(/^[A-Z]+/)?.[0] || ''
  const commonSelect = (sql) => queryJson(`
    select coalesce(json_agg(result), '[]'::json)
    from (
      ${sql}
    ) result;
  `)

  if (prefix === 'ORD' || prefix === 'CKO') {
    await requireAdmin(token, 'order:read')
    return commonSelect(`
      select
        'order' as type,
        ${prefix === 'CKO' ? 'o.settlement_no' : 'o.order_no'} as number,
        '订单' as label,
        o.order_no as title,
        coalesce(p.title, '异常订单：无商品明细') as description,
        o.status as status,
        '/admin/orders/' || ${prefix === 'CKO' ? 'o.settlement_no' : 'o.order_no'} as href
      from orders o
      left join lateral (
        select string_agg(oi.title_snapshot, ' / ' order by oi.title_snapshot) as title
        from order_items oi
        where oi.order_id = o.id
      ) p on true
      where ${prefix === 'CKO'
        ? `o.settlement_no = ${sqlLiteral(query)}`
        : `(o.order_no = ${sqlLiteral(query)} or o.legacy_order_no = ${sqlLiteral(query)})`}
      limit 10
    `)
  }

  if (prefix === 'PAY') {
    await requireAdmin(token, 'payment:read')
    return commonSelect(`
      select
        'payment' as type,
        po.payment_no as number,
        '支付单' as label,
        po.payment_no as title,
        '订单 ' || o.order_no || ' · 金额 ' || po.amount::text as description,
        po.status as status,
        '/admin/payments?selected=' || po.payment_no as href
      from payment_orders po
      join orders o on o.id = po.order_id
      where po.payment_no = ${sqlLiteral(query)}
         or po.legacy_payment_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  if (prefix === 'CON') {
    await requireAdmin(token, 'consulting:read')
    return commonSelect(`
      select
        'consulting' as type,
        cr.request_no as number,
        '咨询需求' as label,
        cr.direction as title,
        cr.company || ' · ' || cr.contact_name || ' / ' || cr.contact_phone as description,
        cr.status as status,
        '/admin/consulting-requests?selected=' || cr.request_no as href
      from consulting_requests cr
      where cr.request_no = ${sqlLiteral(query)}
         or cr.legacy_request_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  if (prefix === 'DSR') {
    await requireAdmin(token, 'data_service:read')
    return commonSelect(`
      select
        'data_service' as type,
        dsr.request_no as number,
        '数据服务' as label,
        dsr.data_topic as title,
        dsr.company || ' · ' || dsr.service_type || ' · 报价 ' || dsr.quoted_amount::text as description,
        dsr.status as status,
        '/admin/data-service/requests?selected=' || dsr.request_no as href
      from data_service_requests dsr
      where dsr.request_no = ${sqlLiteral(query)}
         or dsr.legacy_request_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  if (prefix === 'EXB') {
    await requireAdmin(token, 'expert_booking:read')
    return commonSelect(`
      select
        'expert_booking' as type,
        b.booking_no as number,
        '专家预约' as label,
        e.name || ' · ' || b.contact_name as title,
        coalesce(u.phone, b.contact_phone, '') || ' · ' || to_char(b.starts_at at time zone 'Asia/Shanghai', 'YYYY-MM-DD HH24:MI') as description,
        b.status as status,
        '/admin/expert-bookings?selected=' || b.booking_no as href
      from expert_bookings b
      join expert_profiles e on e.id = b.expert_id
      join users u on u.id = b.user_id
      where b.booking_no = ${sqlLiteral(query)}
         or b.legacy_booking_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  if (prefix === 'REG') {
    await requireAdmin(token, 'event:read')
    return commonSelect(`
      select
        'registration' as type,
        er.registration_no as number,
        '活动报名' as label,
        p.title as title,
        coalesce(u.phone, '') || ' · 核销码 ' || er.checkin_code as description,
        er.status as status,
        '/admin/events/' || er.product_id || '/registrations?selected=' || er.registration_no as href
      from event_registrations er
      join users u on u.id = er.user_id
      join products p on p.id = er.product_id
      where er.registration_no = ${sqlLiteral(query)}
      union all
      select
        'registration' as type,
        cr.registration_no as number,
        '课程报名' as label,
        p.title as title,
        coalesce(u.phone, '') || ' · 核销码 ' || cr.checkin_code as description,
        cr.status as status,
        '/admin/registrations?selected=' || cr.registration_no as href
      from course_registrations cr
      join users u on u.id = cr.user_id
      join products p on p.id = cr.product_id
      where cr.registration_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  if (prefix === 'TSK') {
    await requireAdmin(token, 'task:read')
    return commonSelect(`
      select
        'task' as type,
        ft.task_no as number,
        '交付工单' as label,
        ft.title as title,
        o.order_no || ' · ' || coalesce(u.phone, '') as description,
        ft.status as status,
        '/staff?mode=tasks&selected=' || ft.task_no as href
      from fulfillment_tasks ft
      join orders o on o.id = ft.order_id
      join users u on u.id = o.user_id
      where ft.task_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  if (prefix === 'STL') {
    await requireAdmin(token, 'settlement:read')
    return commonSelect(`
      select
        'settlement' as type,
        sr.settlement_no as number,
        '分账结算' as label,
        p.title as title,
        o.order_no || ' · 专家分成 ' || sr.expert_share_amount::text as description,
        sr.status as status,
        '/admin/settlements?selected=' || sr.settlement_no as href
      from settlement_records sr
      join orders o on o.id = sr.order_id
      join products p on p.id = sr.product_id
      where sr.settlement_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  if (prefix === 'WDR') {
    await requireAdmin(token, 'settlement:read')
    return commonSelect(`
      select
        'withdrawal' as type,
        w.withdrawal_no as number,
        '专家提现' as label,
        e.name || ' · ' || w.payee_name as title,
        w.passport_no || ' · 金额 ' || w.amount::text as description,
        w.status as status,
        '/admin/expert-withdrawals?selected=' || w.withdrawal_no as href
      from expert_withdrawals w
      join expert_profiles e on e.id = w.expert_id
      where w.withdrawal_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  if (prefix === 'EXA') {
    await requireAdmin(token, 'expert_application:read')
    return commonSelect(`
      select
        'expert_application' as type,
        ea.application_no as number,
        '专家申请' as label,
        ea.name || ' · ' || ea.organization as title,
        ea.passport_no || ' · ' || ea.contact_phone as description,
        ea.status as status,
        '/admin/expert-applications?selected=' || ea.application_no as href
      from expert_applications ea
      where ea.application_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  if (prefix === 'COO') {
    await requireAdmin(token, 'cooperation:read')
    return commonSelect(`
      select
        'cooperation' as type,
        cr.request_no as number,
        '合作咨询' as label,
        cr.organization as title,
        cr.name || ' / ' || cr.phone as description,
        cr.status as status,
        '/admin/site/cooperation/' || cr.request_no as href
      from cooperation_requests cr
      where cr.request_no = ${sqlLiteral(query)}
         or cr.legacy_request_no = ${sqlLiteral(query)}
      limit 10
    `)
  }

  return []
}

function dashboardCan(auth, permission) {
  const permissions = auth?.permissions || auth?.account?.permissions || []
  return permissions.includes('*') || permissions.includes(permission)
}

async function safeDashboardQuery(enabled, fallback, sql) {
  if (!enabled) return fallback
  try {
    return await queryJson(sql) || fallback
  } catch {
    return fallback
  }
}

function dashboardSinceSql(range) {
  const key = String(range || '7d')
  if (key === 'today') {
    return { key, label: '今日', sql: `date_trunc('day', now())` }
  }
  if (key === '30d') {
    return { key, label: '近 30 天', sql: `now() - interval '30 days'` }
  }
  return { key: '7d', label: '近 7 天', sql: `now() - interval '7 days'` }
}

function dashboardCount(value) {
  return Number(value || 0)
}

function pushDashboardQueue(items, item) {
  if (!item || dashboardCount(item.count) <= 0) return
  items.push({ ...item, count: dashboardCount(item.count) })
}

export async function getAdminDashboardSummary(token, filters = {}) {
  const auth = await requireAdmin(token, 'staff:me')
  const range = dashboardSinceSql(filters.range)
  const capabilities = {
    orders: dashboardCan(auth, 'order:read'),
    payments: dashboardCan(auth, 'payment:read'),
    tasks: dashboardCan(auth, 'task:read'),
    consulting: dashboardCan(auth, 'consulting:read'),
    dataService: dashboardCan(auth, 'data_service:read'),
    events: dashboardCan(auth, 'event:read') || dashboardCan(auth, 'event_registration:read'),
    experts: dashboardCan(auth, 'expert_booking:read'),
    settlements: dashboardCan(auth, 'settlement:read'),
    invoices: dashboardCan(auth, 'invoice:read'),
    operations: dashboardCan(auth, 'operations:read'),
  }

  const [
    orders,
    payments,
    tasks,
    consulting,
    dataService,
    experts,
    events,
    settlements,
    invoices,
  ] = await Promise.all([
    safeDashboardQuery(capabilities.orders, {}, `
      select json_build_object(
        'totalCount', count(*),
        'paidCount', count(*) filter (where o.status in ('paid', 'completed')),
        'pendingPaymentCount', count(*) filter (where o.status = 'pending_payment'),
        'closedCount', count(*) filter (where o.status = 'closed'),
        'paidAmount', coalesce(sum(case when o.status in ('paid', 'completed') then coalesce(nullif(o.paid_amount, 0), o.total_amount) else 0 end), 0),
        'pendingAmount', coalesce(sum(case when o.status = 'pending_payment' then o.total_amount else 0 end), 0),
        'recentItems', coalesce((
          select json_agg(json_build_object(
            'key', ro.order_no,
            'label', '订单',
            'title', ro.order_no,
            'description', coalesce(oi.item_summary, '异常订单：无商品明细') || ' · ' || coalesce(u.phone, '未留手机号'),
            'status', ro.status,
            'amount', ro.total_amount,
            'href', '/admin/orders/' || ro.order_no,
            'createdAt', ro.created_at
          ) order by ro.created_at desc)
          from (
            select *
            from orders
            where created_at >= ${range.sql}
            order by created_at desc
            limit 8
          ) ro
          join users u on u.id = ro.user_id
          left join lateral (
            select string_agg(oi.title_snapshot, ' / ' order by oi.title_snapshot) as item_summary
            from order_items oi
            where oi.order_id = ro.id
          ) oi on true
        ), '[]'::json)
      )
      from orders o
      where o.created_at >= ${range.sql};
    `),
    safeDashboardQuery(capabilities.payments, {}, `
      select json_build_object(
        'createdCount', count(*) filter (where status in ('created', 'paying')),
        'succeededCount', count(*) filter (where status = 'succeeded'),
        'failedCount', count(*) filter (where status in ('failed', 'closed')),
        'timeoutCount', count(*) filter (where status in ('created', 'paying') and expires_at <= now()),
        'createdAmount', coalesce(sum(case when status in ('created', 'paying') then amount else 0 end), 0)
      )
      from payment_orders
      where created_at >= ${range.sql};
    `),
    safeDashboardQuery(capabilities.tasks, {}, `
      select json_build_object(
        'openCount', count(*) filter (where ft.status <> 'completed'),
        'pendingMaterialCount', count(*) filter (where ft.status = 'pending_material'),
        'inProgressCount', count(*) filter (where ft.status in ('assigned', 'in_progress', 'pending_confirm')),
        'completedCount', count(*) filter (where ft.status = 'completed'),
        'queue', coalesce((
          select json_agg(json_build_object(
            'key', q.task_no,
            'label', '交付',
            'title', q.title,
            'description', coalesce(q.product_title, '') || ' · ' || coalesce(q.phone, ''),
            'status', q.status,
            'href', '/staff?mode=tasks&selected=' || q.task_no,
            'updatedAt', q.updated_at
          ) order by q.updated_at desc)
          from (
            select ft2.task_no, ft2.title, ft2.status, ft2.updated_at, p.title as product_title, u.phone
            from fulfillment_tasks ft2
            join orders o on o.id = ft2.order_id
            join users u on u.id = o.user_id
            join products p on p.id = ft2.product_id
            where ft2.status <> 'completed'
            order by ft2.updated_at desc
            limit 8
          ) q
        ), '[]'::json)
      )
      from fulfillment_tasks ft
      where ft.created_at >= ${range.sql};
    `),
    safeDashboardQuery(capabilities.consulting, {}, `
      select json_build_object(
        'totalCount', count(*),
        'pendingCount', count(*) filter (where status in ('pending', 'assigned')),
        'activeCount', count(*) filter (where status in ('communicating', 'planning', 'proposal_ready', 'pending_payment', 'in_service')),
        'payableCount', count(*) filter (where status = 'pending_payment'),
        'completedCount', count(*) filter (where status = 'completed')
      )
      from consulting_requests
      where created_at >= ${range.sql};
    `),
    safeDashboardQuery(capabilities.dataService, {}, `
      select json_build_object(
        'totalCount', count(*),
        'pendingCount', count(*) filter (where status in ('pending_contact', 'contacting')),
        'activeCount', count(*) filter (where status in ('quoted', 'pending_payment', 'in_service')),
        'payableCount', count(*) filter (where status = 'pending_payment'),
        'completedCount', count(*) filter (where status = 'completed')
      )
      from data_service_requests
      where created_at >= ${range.sql};
    `),
    safeDashboardQuery(capabilities.experts, {}, `
      select json_build_object(
        'totalCount', count(*),
        'pendingPaymentCount', count(*) filter (where status = 'pending_payment'),
        'pendingExpertCount', count(*) filter (where status in ('pending_expert', 'pending_confirm')),
        'activeCount', count(*) filter (where status in ('confirmed', 'in_service', 'pending_user_confirm')),
        'completedCount', count(*) filter (where status = 'completed')
      )
      from expert_bookings
      where created_at >= ${range.sql};
    `),
    safeDashboardQuery(capabilities.events, {}, `
      select json_build_object(
        'totalCount', count(*),
        'pendingPaymentCount', count(*) filter (where status = 'pending_payment'),
        'registeredCount', count(*) filter (where status in ('registered', 'waitlisted')),
        'checkedInCount', count(*) filter (where status = 'checked_in'),
        'cancelledCount', count(*) filter (where status = 'cancelled')
      )
      from event_registrations
      where created_at >= ${range.sql};
    `),
    safeDashboardQuery(capabilities.settlements, {}, `
      select json_build_object(
        'totalCount', count(*),
        'pendingCount', count(*) filter (where status not in ('paid', 'completed', 'withdrawn')),
        'paidCount', count(*) filter (where status in ('paid', 'completed', 'withdrawn')),
        'pendingAmount', coalesce(sum(case when status not in ('paid', 'completed', 'withdrawn') then expert_share_amount else 0 end), 0)
      )
      from settlement_records
      where created_at >= ${range.sql};
    `),
    safeDashboardQuery(capabilities.invoices, {}, `
      select json_build_object(
        'pendingCount', count(*) filter (where status in ('pending', 'processing')),
        'failedCount', count(*) filter (where status in ('failed', 'rejected')),
        'issuedCount', count(*) filter (where status = 'issued')
      )
      from invoice_applications
      where created_at >= ${range.sql};
    `),
  ])

  let exceptionList = { items: [], summary: { open: 0, acknowledged: 0, resolved: 0, critical: 0, error: 0 }, total: 0 }
  if (capabilities.operations) {
    try {
      exceptionList = await listAdminOperationalEvents(token, { page: 1, pageSize: 8 })
    } catch {
      exceptionList = { items: [], summary: { open: 0, acknowledged: 0, resolved: 0, critical: 0, error: 0 }, total: 0 }
    }
  }

  const workQueues = []
  pushDashboardQueue(workQueues, { key: 'payment_timeout', label: '支付超时', count: payments.timeoutCount, tone: 'danger', href: '/admin/exceptions?type=payment.order.timeout', description: '创建/支付中但已过期' })
  pushDashboardQueue(workQueues, { key: 'consulting_pending', label: '咨询待受理', count: consulting.pendingCount, tone: 'warning', href: '/admin/consulting-requests?status=pending', description: '待分派、待沟通' })
  pushDashboardQueue(workQueues, { key: 'data_service_pending', label: '数据待联系', count: dataService.pendingCount, tone: 'warning', href: '/admin/data-service/requests?status=pending_contact', description: '需求提交后待联系' })
  pushDashboardQueue(workQueues, { key: 'task_open', label: '交付待办', count: tasks.openCount, tone: 'default', href: '/staff?mode=tasks', description: '未完成交付任务' })
  pushDashboardQueue(workQueues, { key: 'expert_pending', label: '专家待确认', count: experts.pendingExpertCount, tone: 'default', href: '/admin/expert-bookings', description: '待专家确认或处理' })
  pushDashboardQueue(workQueues, { key: 'invoice_pending', label: '发票待处理', count: invoices.pendingCount, tone: 'default', href: '/admin/invoices?status=pending', description: '待开票/处理中' })
  pushDashboardQueue(workQueues, { key: 'settlement_pending', label: '专家待结算', count: settlements.pendingCount, tone: 'default', href: '/admin/settlements', description: '未完成分账提现' })
  pushDashboardQueue(workQueues, { key: 'exception_open', label: '异常待处理', count: exceptionList.summary.open, tone: 'danger', href: '/admin/exceptions?status=open', description: '未处理异常事件' })

  const businessLines = [
    capabilities.orders && {
      key: 'orders',
      label: '订单交易',
      total: dashboardCount(orders.totalCount),
      pending: dashboardCount(orders.pendingPaymentCount),
      active: dashboardCount(orders.paidCount),
      completed: dashboardCount(orders.closedCount),
      risk: dashboardCount(payments.timeoutCount),
      href: '/admin/orders',
    },
    capabilities.consulting && {
      key: 'consulting',
      label: '规划咨询',
      total: dashboardCount(consulting.totalCount),
      pending: dashboardCount(consulting.pendingCount),
      active: dashboardCount(consulting.activeCount),
      completed: dashboardCount(consulting.completedCount),
      risk: dashboardCount(consulting.payableCount),
      href: '/admin/consulting-requests',
    },
    capabilities.dataService && {
      key: 'data_service',
      label: '数据服务',
      total: dashboardCount(dataService.totalCount),
      pending: dashboardCount(dataService.pendingCount),
      active: dashboardCount(dataService.activeCount),
      completed: dashboardCount(dataService.completedCount),
      risk: dashboardCount(dataService.payableCount),
      href: '/admin/data-service/requests',
    },
    capabilities.experts && {
      key: 'experts',
      label: '专家预约',
      total: dashboardCount(experts.totalCount),
      pending: dashboardCount(experts.pendingPaymentCount),
      active: dashboardCount(experts.pendingExpertCount) + dashboardCount(experts.activeCount),
      completed: dashboardCount(experts.completedCount),
      risk: dashboardCount(experts.pendingExpertCount),
      href: '/admin/expert-bookings',
    },
    capabilities.events && {
      key: 'events',
      label: '活动报名',
      total: dashboardCount(events.totalCount),
      pending: dashboardCount(events.pendingPaymentCount),
      active: dashboardCount(events.registeredCount),
      completed: dashboardCount(events.checkedInCount),
      risk: dashboardCount(events.cancelledCount),
      href: '/admin/events',
    },
    capabilities.settlements && {
      key: 'settlements',
      label: '财务结算',
      total: dashboardCount(settlements.totalCount),
      pending: dashboardCount(settlements.pendingCount),
      active: dashboardCount(settlements.pendingAmount),
      completed: dashboardCount(settlements.paidCount),
      risk: dashboardCount(invoices.failedCount),
      href: '/admin/settlements',
      activeType: 'money',
    },
  ].filter(Boolean)

  const metrics = [
    capabilities.orders && {
      key: 'paidAmount',
      label: '成交额',
      value: dashboardCount(orders.paidAmount),
      valueType: 'money',
      helper: `${dashboardCount(orders.paidCount)} 单已支付`,
      href: '/admin/orders?status=paid',
    },
    capabilities.orders && {
      key: 'pendingPayment',
      label: '待支付订单',
      value: dashboardCount(orders.pendingPaymentCount),
      valueType: 'number',
      helper: '需要继续支付或关闭',
      href: '/admin/orders?status=pending_payment',
    },
    {
      key: 'servicePending',
      label: '服务待受理',
      value: dashboardCount(consulting.pendingCount) + dashboardCount(dataService.pendingCount),
      valueType: 'number',
      helper: '咨询 + 数据服务',
      href: '/admin/search',
    },
    capabilities.tasks && {
      key: 'taskOpen',
      label: '交付待办',
      value: dashboardCount(tasks.openCount),
      valueType: 'number',
      helper: `${dashboardCount(tasks.inProgressCount)} 个处理中`,
      href: '/staff?mode=tasks',
    },
    capabilities.operations && {
      key: 'criticalExceptions',
      label: '严重异常',
      value: dashboardCount(exceptionList.summary.critical) + dashboardCount(exceptionList.summary.error),
      valueType: 'number',
      helper: `${dashboardCount(exceptionList.summary.open)} 个待处理`,
      href: '/admin/exceptions?status=open',
    },
    capabilities.settlements && {
      key: 'pendingSettlement',
      label: '待结专家收益',
      value: dashboardCount(settlements.pendingAmount),
      valueType: 'money',
      helper: `${dashboardCount(settlements.pendingCount)} 条待处理`,
      href: '/admin/settlements',
    },
  ].filter(Boolean)

  return {
    range: { key: range.key, label: range.label },
    capabilities,
    metrics,
    workQueues: workQueues.sort((a, b) => {
      const toneRank = { danger: 0, warning: 1, default: 2 }
      return (toneRank[a.tone] ?? 2) - (toneRank[b.tone] ?? 2) || b.count - a.count
    }),
    businessLines,
    risks: exceptionList.items || [],
    recentOrders: orders.recentItems || [],
    fulfillmentQueue: tasks.queue || [],
  }
}

export async function requireAdmin(token, permission = 'admin:access') {
  if (adminToken && token === adminToken) {
    return {
      actor: 'lite-admin',
      account: {
        id: 'dev-admin',
        email: 'dev-admin@local',
        phone: '',
        displayName: '开发管理员',
        role: 'super_admin',
        permissions: ['*'],
      },
      permissions: ['*'],
    }
  }

  const account = await getStaffAccountByToken(token)
  if (!account) {
    const error = new Error('管理端令牌无效')
    error.statusCode = 401
    error.code = 'admin_unauthorized'
    throw error
  }

  const permissions = await getStaffEffectivePermissions(account)
  if (!permissions.includes('*') && permission !== 'staff:me' && !permissions.includes(permission)) {
    const error = new Error('当前账号没有操作权限')
    error.statusCode = 403
    error.code = 'permission_denied'
    throw error
  }

  return {
    actor: account.email,
    account: await publicStaffAccount(account, permissions),
    permissions,
  }
}

async function requireAdminAny(token, permissions = []) {
  let lastPermissionError = null
  for (const permission of permissions) {
    try {
      return await requireAdmin(token, permission)
    } catch (error) {
      if (error?.statusCode !== 403) throw error
      lastPermissionError = error
    }
  }
  if (lastPermissionError) throw lastPermissionError
  return requireAdmin(token)
}

function staffPermissionList(account = {}, permissions = null) {
  if (Array.isArray(permissions)) return permissions
  if (Array.isArray(account?.permissions)) return account.permissions
  return []
}

function staffHasPermission(account, permission, permissions = null) {
  const list = staffPermissionList(account, permissions)
  return list.includes('*') || list.includes(permission)
}

function staffCanSeeGlobalPool(account, permissions = null) {
  if (!account) return false
  if (account.id === 'dev-admin') return true
  if (['super_admin', 'operations_admin'].includes(account.role)) return true
  return staffHasPermission(account, 'operations:read', permissions)
}

function staffAssigneeId(account) {
  return account?.id && account.id !== 'dev-admin' && isUuid(account.id) ? account.id : ''
}

function staffScopeCondition(assigneeColumn, account, filters = {}, permissions = null) {
  const scope = String(filters.scope || '').trim()
  const accountId = staffAssigneeId(account)
  if (staffCanSeeGlobalPool(account, permissions)) {
    if (scope === 'mine') return accountId ? `${assigneeColumn} = ${sqlLiteral(accountId)}::uuid` : 'true'
    if (scope === 'pool') return accountId ? `(${assigneeColumn} is null or ${assigneeColumn} = ${sqlLiteral(accountId)}::uuid)` : `${assigneeColumn} is null`
    return 'true'
  }
  if (scope === 'mine') return accountId ? `${assigneeColumn} = ${sqlLiteral(accountId)}::uuid` : 'false'
  return accountId ? `(${assigneeColumn} is null or ${assigneeColumn} = ${sqlLiteral(accountId)}::uuid)` : `${assigneeColumn} is null`
}

function staffRecordVisible(assigneeColumn, account, permissions = null) {
  if (staffCanSeeGlobalPool(account, permissions)) return 'true'
  const accountId = staffAssigneeId(account)
  return accountId ? `(${assigneeColumn} is null or ${assigneeColumn} = ${sqlLiteral(accountId)}::uuid)` : `${assigneeColumn} is null`
}

function assertStaffCanTouchAssigned(current, account, permissions = null, field = 'assignedAccountId') {
  if (staffCanSeeGlobalPool(account, permissions)) return
  const accountId = staffAssigneeId(account)
  const assignedId = current?.[field] || current?.assigned_account_id || current?.assigneeAccountId || ''
  if (assignedId && (!accountId || String(assignedId) !== accountId)) {
    const error = new Error('当前账号不能处理其他员工负责的事项')
    error.statusCode = 403
    error.code = 'staff_scope_denied'
    throw error
  }
}

export async function updateProfile(token, patch) {
  const { user } = await requireUser(token)
  return queryJson(`
    update users
    set
      display_name = coalesce(${sqlLiteral(patch.displayName ?? patch.name)}, display_name),
      contact_phone = coalesce(${sqlLiteral(patch.contactPhone ?? patch.contact_phone)}, contact_phone),
      email = coalesce(${sqlLiteral(patch.email)}, email),
      organization = coalesce(${sqlLiteral(patch.organization)}, organization),
      title = coalesce(${sqlLiteral(patch.title)}, title),
      city = coalesce(${sqlLiteral(patch.city)}, city),
      address = coalesce(${sqlLiteral(patch.address)}, address),
      industry = coalesce(${sqlLiteral(patch.industry)}, industry),
      invoice_title = coalesce(${sqlLiteral(patch.invoiceTitle ?? patch.invoice_title)}, invoice_title),
      tax_number = coalesce(${sqlLiteral(patch.taxNumber ?? patch.tax_number)}, tax_number),
      invoice_email = coalesce(${sqlLiteral(patch.invoiceEmail ?? patch.invoice_email)}, invoice_email),
      updated_at = now()
    where id = ${sqlLiteral(user.id)}::uuid
    returning ${userJson('users')};
  `)
}

export async function completeOnboarding(token, payload) {
  const profile = await updateProfile(token, payload)
  return queryJson(`
    update users
    set onboarding_completed = true, updated_at = now()
    where id = ${sqlLiteral(profile.id)}::uuid
    returning ${userJson('users')};
  `)
}

export async function bindAccountWechat(token, payload = {}) {
  const { user } = await requireUser(token)
  const wechatId = normalizeWechatId(payload.wechatId || payload.wechat_id)
  if (!wechatId) {
    const error = new Error('请填写微信号')
    error.statusCode = 400
    error.code = 'invalid_wechat_id'
    throw error
  }
  const existingIdentity = await queryJson(`
    select json_build_object('id', i.id, 'provider', i.provider, 'appId', i.app_id)
    from user_wechat_identities i
    where i.user_id = ${sqlLiteral(user.id)}::uuid
    limit 1;
  `)
  if (existingIdentity) {
    const error = new Error('已通过微信注册或绑定的账号不支持修改微信号，请联系客服处理')
    error.statusCode = 409
    error.code = 'wechat_identity_locked'
    throw error
  }
  if (user.wechatId && user.wechatId !== wechatId) {
    const error = new Error('已绑定微信号不支持自行修改，请联系客服处理')
    error.statusCode = 409
    error.code = 'wechat_id_locked'
    throw error
  }
  if (user.wechatId === wechatId) return user
  const existing = await queryJson(`
    select ${userJson('u')}
    from users u
    where u.wechat_id = ${sqlLiteral(wechatId)}
      and u.id <> ${sqlLiteral(user.id)}::uuid
    limit 1;
  `)
  if (existing) {
    const error = new Error('该微信号已绑定其他账户')
    error.statusCode = 409
    error.code = 'wechat_already_bound'
    throw error
  }
  return queryJson(`
    update users
    set wechat_id = ${sqlLiteral(wechatId)},
        updated_at = now()
    where id = ${sqlLiteral(user.id)}::uuid
    returning ${userJson('users')};
  `)
}

export async function closeAccount(token, payload = {}) {
  const { user } = await requireUser(token)
  const reason = String(payload.reason || payload.closeReason || payload.close_reason || '用户主动注销账号').trim().slice(0, 200) || '用户主动注销账号'
  const updated = await queryJson(`
    with deleted_miniapp_sessions as (
      delete from miniapp_auth_sessions mas
      using user_wechat_identities iwi
      where iwi.user_id = ${sqlLiteral(user.id)}::uuid
        and mas.app_id = iwi.app_id
        and mas.openid = iwi.openid
      returning mas.token
    ),
    deleted_wechat_identities as (
      delete from user_wechat_identities
      where user_id = ${sqlLiteral(user.id)}::uuid
      returning id
    ),
    deleted_user_sessions as (
      delete from sessions
      where user_id = ${sqlLiteral(user.id)}::uuid
      returning token
    ),
    cancelled_pc_sessions as (
      update pc_login_sessions
      set status = 'cancelled',
          exchange_code = null,
          updated_at = now()
      where user_id = ${sqlLiteral(user.id)}::uuid
        and status not in ('cancelled', 'expired')
      returning session_id
    ),
    cancel_membership_auto_renew as (
      update membership_subscriptions
      set status = case
            when status in ('active', 'past_due') then 'canceled'
            else status
          end,
          auto_renew = false,
          ended_at = coalesce(ended_at, now()),
          canceled_at = coalesce(canceled_at, now()),
          cancel_requested_at = coalesce(cancel_requested_at, now()),
          cancel_requested_reason = case
            when cancel_requested_reason = '' then ${sqlLiteral(reason)}
            else cancel_requested_reason
          end,
          updated_at = now()
      where user_id = ${sqlLiteral(user.id)}::uuid
        and status in ('active', 'past_due')
      returning id
    )
    update users
    set status = 'disabled',
        phone = null,
        wechat_id = null,
        display_name = '已注销用户',
        contact_phone = '',
        email = '',
        organization = '',
        title = '',
        city = '',
        address = '',
        industry = '',
        invoice_title = '',
        tax_number = '',
        invoice_email = '',
        membership_level = 'registered',
        onboarding_completed = false,
        updated_at = now()
    where id = ${sqlLiteral(user.id)}::uuid
      and status = 'active'
    returning ${userJson('users')};
  `)
  if (!updated) {
    const error = new Error('账号已注销或不存在')
    error.statusCode = 404
    error.code = 'account_already_closed'
    throw error
  }
  return {
    ok: true,
    closedAt: updated.updatedAt,
    user: updated,
  }
}

export async function getPassportVerification(passportNo) {
  passportNo = normalizePassportNo(passportNo)
  if (!passportNo) return null
  const item = await queryJson(`
    select json_build_object(
      'userId', u.id,
      'passportNo', u.passport_no,
      'displayName', u.display_name,
      'phoneTail', case when u.phone is null or u.phone = '' then '' else right(u.phone, 4) end,
      'organization', u.organization,
      'membershipLevel', u.membership_level,
      'status', u.status,
      'expertStatus', coalesce((
        select e.status
        from expert_profiles e
        where e.user_id = u.id
          and e.deleted_at is null
        order by e.updated_at desc
        limit 1
      ), ''),
      'verifiedAt', now(),
      'createdAt', u.created_at,
      'updatedAt', u.updated_at
    )
    from users u
    where u.passport_no = ${sqlLiteral(passportNo)}
    limit 1;
  `)
  if (!item) return null
  const membership = await resolveUserMembership(item.userId)
  return {
    ...item,
    membershipLevel: membership.level,
    membershipStatus: membership.status,
    membershipExpiresAt: membership.expiresAt,
    membershipDaysLeft: membership.daysLeft,
    membershipSubscriptionId: membership.subscription?.id || null,
  }
}

export async function verifyAdminPassport(token, rawQuery) {
  await requireAdminAny(token, ['passport:read', 'user:read'])
  const query = String(rawQuery || '').trim()
  if (!query) {
    const error = new Error('请输入通行证号或手机号')
    error.statusCode = 400
    error.code = 'passport_query_required'
    throw error
  }
  let passportNo = normalizePassportNo(query)
  if (!passportNo) {
    const phone = normalizePhone(query)
    const user = phone ? await queryJson(`
      select json_build_object('passportNo', passport_no)
      from users
      where phone = ${sqlLiteral(phone)}
      limit 1;
    `) : null
    passportNo = user?.passportNo || ''
  }
  const item = await getPassportVerification(passportNo)
  if (!item) {
    const error = new Error('未找到对应通行证或用户')
    error.statusCode = 404
    error.code = 'passport_not_found'
    throw error
  }
  const summary = await queryJson(`
    select json_build_object(
      'orderCount', coalesce((select count(*)::int from orders o where o.user_id = ${sqlLiteral(item.userId)}::uuid), 0),
      'courseRegistrationCount', coalesce((select count(*)::int from course_registrations cr where cr.user_id = ${sqlLiteral(item.userId)}::uuid), 0),
      'eventRegistrationCount', coalesce((select count(*)::int from event_registrations er where er.user_id = ${sqlLiteral(item.userId)}::uuid), 0),
      'consultingRequestCount', coalesce((select count(*)::int from consulting_requests crq where crq.user_id = ${sqlLiteral(item.userId)}::uuid), 0),
      'dataServiceRequestCount', coalesce((select count(*)::int from data_service_requests dsr where dsr.user_id = ${sqlLiteral(item.userId)}::uuid), 0),
      'expertBookingCount', coalesce((select count(*)::int from expert_bookings eb where eb.user_id = ${sqlLiteral(item.userId)}::uuid), 0)
    );
  `)
  return { ...item, summary: summary || {} }
}

export async function listAdminUsers(token, filters = {}) {
  await requireAdmin(token, 'user:read')
  const search = String(filters.search || '').trim()
  const status = String(filters.status || '').trim()
  return queryJson(`
    select coalesce(json_agg(user_json order by (user_json->>'createdAt') desc), '[]'::json)
    from (
      select (${userJson('u')})::jsonb || jsonb_build_object(
        'orderCount', coalesce((select count(*)::int from orders o where o.user_id = u.id), 0),
        'taskCount', coalesce((select count(*)::int from fulfillment_tasks ft join orders o on o.id = ft.order_id where o.user_id = u.id), 0),
        'consultingCount', coalesce((select count(*)::int from consulting_requests cr where cr.user_id = u.id), 0),
        'dataServiceCount', coalesce((select count(*)::int from data_service_requests dsr where dsr.user_id = u.id), 0),
        'expertBookingCount', coalesce((select count(*)::int from expert_bookings b where b.user_id = u.id), 0)
      ) as user_json
      from users u
      where (${sqlLiteral(status)} = '' or u.status = ${sqlLiteral(status)})
        and (${sqlLiteral(search)} = ''
          or u.phone ilike '%' || ${sqlLiteral(search)} || '%'
          or u.passport_no ilike '%' || ${sqlLiteral(search)} || '%'
          or u.wechat_id ilike '%' || ${sqlLiteral(search)} || '%'
          or u.display_name ilike '%' || ${sqlLiteral(search)} || '%')
    ) t;
  `)
}

function adminUserWhere(userId, alias = 'u') {
  const key = String(userId || '').trim()
  const passportNo = normalizePassportNo(key)
  if (isUuid(key)) return `${alias}.id = ${sqlLiteral(key)}::uuid`
  return `(
    ${alias}.passport_no = ${sqlLiteral(passportNo)}
    or ${alias}.phone = ${sqlLiteral(normalizePhone(key))}
    or ${alias}.wechat_id = ${sqlLiteral(normalizeWechatId(key))}
  )`
}

function invoiceApplicationJson(alias = 'ia', orderAlias = 'o', userAlias = 'u', fileAlias = 'f') {
  return `json_build_object(
    'id', ${alias}.id,
    'invoiceNo', ${alias}.invoice_no,
    'invoiceType', coalesce(${alias}.invoice_type, 'normal_e_invoice'),
    'invoiceTypeLabel', '普通电子发票',
    'orderId', ${alias}.order_id,
    'orderNo', ${orderAlias}.order_no,
    'settlementNo', ${orderAlias}.settlement_no,
    'userId', ${orderAlias}.user_id,
    'userPhone', ${userAlias}.phone,
    'title', ${alias}.title,
    'taxNumber', coalesce(${alias}.tax_number, ''),
    'email', ${alias}.email,
    'amount', ${alias}.amount,
    'status', ${alias}.status,
    'provider', coalesce(${alias}.provider, 'manual'),
    'providerInvoiceId', coalesce(${alias}.provider_invoice_id, ''),
    'pdfFileId', ${alias}.pdf_file_id,
    'fileName', coalesce(${fileAlias}.file_name, ''),
    'fileUrl', case when ${alias}.pdf_file_id is null then '' else '/api/files/' || ${alias}.pdf_file_id end,
    'failureReason', coalesce(${alias}.failure_reason, ''),
    'remark', coalesce(${alias}.remark, ''),
    'adminNote', coalesce(${alias}.admin_note, ''),
    'issuedBy', coalesce(${alias}.issued_by, ''),
    'issuedAt', ${alias}.issued_at,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at,
    'fileUploadedAt', ${alias}.file_uploaded_at
  )`
}

function invoiceApplicationSummaryForOrder(orderAlias = 'o') {
  return `(
    select ${invoiceApplicationJson('ia', orderAlias, 'iu', 'f')}
    from invoice_applications ia
    join users iu on iu.id = ${orderAlias}.user_id
    left join uploaded_files f on f.id = ia.pdf_file_id
    where ia.order_id = ${orderAlias}.id
    order by ia.created_at desc
    limit 1
  )`
}

function invoiceStatusForOrder(orderAlias = 'o') {
  return `coalesce((
    select ia.status
    from invoice_applications ia
    where ia.order_id = ${orderAlias}.id
    order by ia.created_at desc
    limit 1
  ), '')`
}

function adminOrderJson(orderAlias = 'o', userAlias = 'u') {
  return `json_build_object(
    'id', ${orderAlias}.id,
    'orderNo', ${orderAlias}.order_no,
    'settlementNo', ${orderAlias}.settlement_no,
    'userId', ${orderAlias}.user_id,
    'userPhone', ${userAlias}.phone,
    'status', ${orderAlias}.status,
    'items', coalesce((
      select json_agg(json_build_object(
        'productId', oi.product_id,
        'productType', oi.product_type,
        'titleSnapshot', oi.title_snapshot,
        'unitPriceAmount', oi.unit_price_amount,
        'quantity', oi.quantity,
        'payableAmount', oi.payable_amount,
        'billingCycle', coalesce(oi.billing_cycle, 'once')
      ))
      from order_items oi
      where oi.order_id = ${orderAlias}.id
    ), '[]'::json),
    'subtotalAmount', ${orderAlias}.subtotal_amount,
    'memberDiscountAmount', ${orderAlias}.member_discount_amount,
    'couponDiscountAmount', ${orderAlias}.coupon_discount_amount,
    'paidAmount', ${orderAlias}.paid_amount,
    'totalAmount', ${orderAlias}.total_amount,
    'currency', ${orderAlias}.currency,
    'settlementSnapshot', ${orderAlias}.settlement_snapshot,
    'invoiceStatus', ${invoiceStatusForOrder(orderAlias)},
    'invoiceApplication', ${invoiceApplicationSummaryForOrder(orderAlias)},
    'createdAt', ${orderAlias}.created_at,
    'updatedAt', ${orderAlias}.updated_at
  )`
}

function settlementRecordJson(alias = 'sr', orderAlias = 'o', userAlias = 'u', productAlias = 'p') {
  return `json_build_object(
    'id', ${alias}.id,
    'settlementNo', ${alias}.settlement_no,
    'orderId', ${alias}.order_id,
    'orderNo', ${orderAlias}.order_no,
    'userId', ${alias}.user_id,
    'userPhone', ${userAlias}.phone,
    'productId', ${alias}.product_id,
    'productTitle', ${productAlias}.title,
    'beneficiaryType', ${alias}.beneficiary_type,
    'beneficiaryRef', ${alias}.beneficiary_ref,
    'expertId', ${alias}.expert_id,
    'beneficiaryUserId', ${alias}.beneficiary_user_id,
    'passportNo', ${alias}.passport_no,
    'grossAmount', ${alias}.gross_amount,
    'expertShareAmount', ${alias}.expert_share_amount,
    'platformShareAmount', ${alias}.platform_share_amount,
    'status', ${alias}.status,
    'policySnapshot', ${alias}.policy_snapshot,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

export async function getAdminUserDetail(token, userId) {
  await requireAdmin(token, 'user:read')
  const user = await queryJson(`
    select (${userJson('u')})::jsonb || jsonb_build_object(
      'orderCount', coalesce((select count(*)::int from orders o where o.user_id = u.id), 0),
      'taskCount', coalesce((select count(*)::int from fulfillment_tasks ft join orders o on o.id = ft.order_id where o.user_id = u.id), 0),
      'consultingCount', coalesce((select count(*)::int from consulting_requests cr where cr.user_id = u.id), 0),
      'dataServiceCount', coalesce((select count(*)::int from data_service_requests dsr where dsr.user_id = u.id), 0),
      'courseRegistrationCount', coalesce((select count(*)::int from course_registrations cr where cr.user_id = u.id), 0),
      'eventRegistrationCount', coalesce((select count(*)::int from event_registrations er where er.user_id = u.id), 0),
      'expertBookingCount', coalesce((select count(*)::int from expert_bookings b where b.user_id = u.id), 0)
    )
    from users u
    where ${adminUserWhere(userId, 'u')}
    limit 1;
  `)
  if (!user) return null

  const [
    orders,
    tasks,
    consultingRequests,
    dataServiceRequests,
    courseRegistrations,
    eventRegistrations,
    expertApplication,
    expertProfile,
  ] = await Promise.all([
    queryJson(`
      select coalesce(json_agg(${adminOrderJson('o', 'u')} order by o.created_at desc), '[]'::json)
      from orders o
      join users u on u.id = o.user_id
      where o.user_id = ${sqlLiteral(user.id)}::uuid;
    `),
    queryJson(`
      select coalesce(json_agg(${fulfillmentTaskJson('ft', 'o', 'u', 'p', 'sa')} order by ft.created_at desc), '[]'::json)
      from fulfillment_tasks ft
      join orders o on o.id = ft.order_id
      join users u on u.id = o.user_id
      join products p on p.id = ft.product_id
      left join staff_accounts sa on sa.id = ft.assignee_account_id
      where o.user_id = ${sqlLiteral(user.id)}::uuid;
    `),
    queryJson(`
      select coalesce(json_agg(request_json order by (request_json->>'createdAt') desc), '[]'::json)
      from (
        select (${consultingRequestJson('cr')})::jsonb || jsonb_build_object(
          'userPhone', u.phone,
          'assigneeName', sa.display_name
        ) as request_json
        from consulting_requests cr
        join users u on u.id = cr.user_id
        left join staff_accounts sa on sa.id = cr.assigned_account_id
        where cr.user_id = ${sqlLiteral(user.id)}::uuid
      ) t;
    `),
    queryJson(`
      select coalesce(json_agg(${dataServiceRequestJson('dsr', 'u', 'sa', 'o', 'f')} order by dsr.created_at desc), '[]'::json)
      from data_service_requests dsr
      join users u on u.id = dsr.user_id
      left join staff_accounts sa on sa.id = dsr.assigned_account_id
      left join orders o on o.id = dsr.order_id
      left join uploaded_files f on f.id = dsr.deliverable_file_id
      where dsr.user_id = ${sqlLiteral(user.id)}::uuid;
    `),
    queryJson(`
      select coalesce(json_agg(${courseRegistrationJson('cr', 'u', 'p', 'd', 'o', 'sa')} order by cr.created_at desc), '[]'::json)
      from course_registrations cr
      join users u on u.id = cr.user_id
      join products p on p.id = cr.product_id
      join course_product_details d on d.product_id = p.id
      left join orders o on o.id = cr.order_id
      left join staff_accounts sa on sa.id = cr.checked_in_by_staff_id
      where cr.user_id = ${sqlLiteral(user.id)}::uuid;
    `),
    queryJson(`
      select coalesce(json_agg(${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')} order by er.created_at desc), '[]'::json)
      from event_registrations er
      join users u on u.id = er.user_id
      join products p on p.id = er.product_id
      join event_product_details d on d.product_id = p.id
      left join orders o on o.id = er.order_id
      left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
      where er.user_id = ${sqlLiteral(user.id)}::uuid;
    `),
    queryJson(`
      select ${expertApplicationJson('ea', 'u')}
      from expert_applications ea
      join users u on u.id = ea.user_id
      where ea.user_id = ${sqlLiteral(user.id)}::uuid
      order by ea.created_at desc
      limit 1;
    `),
    queryJson(`
      select (${expertProfileJson('e')})::jsonb || jsonb_build_object(
        'bookingCount', coalesce((select count(*)::int from expert_bookings b where b.expert_id = e.id), 0),
        'completedBookingCount', coalesce((select count(*)::int from expert_bookings b where b.expert_id = e.id and b.status = 'completed'), 0)
      )
      from expert_profiles e
      where e.user_id = ${sqlLiteral(user.id)}::uuid
        and e.deleted_at is null
      order by e.updated_at desc
      limit 1;
    `),
  ])

  const expertProfileId = expertProfile?.id || null
  const [expertBookings, expertWithdrawals, settlements] = await Promise.all([
    queryJson(`
      select coalesce(json_agg(${expertBookingJson('b', 'e', 'u')} order by b.starts_at desc), '[]'::json)
      from expert_bookings b
      join expert_profiles e on e.id = b.expert_id
      join users u on u.id = b.user_id
      where b.user_id = ${sqlLiteral(user.id)}::uuid
        ${expertProfileId ? `or b.expert_id = ${sqlLiteral(expertProfileId)}::uuid` : ''};
    `),
    expertProfileId ? queryJson(`
      select coalesce(json_agg(${expertWithdrawalJson('w', 'e', 'u')} order by w.created_at desc), '[]'::json)
      from expert_withdrawals w
      join expert_profiles e on e.id = w.expert_id
      join users u on u.id = w.user_id
      where w.user_id = ${sqlLiteral(user.id)}::uuid
         or w.expert_id = ${sqlLiteral(expertProfileId)}::uuid;
    `) : Promise.resolve([]),
    queryJson(`
      select coalesce(json_agg(${settlementRecordJson('sr', 'o', 'u', 'p')} order by sr.created_at desc), '[]'::json)
      from settlement_records sr
      join orders o on o.id = sr.order_id
      join users u on u.id = sr.user_id
      join products p on p.id = sr.product_id
      where sr.user_id = ${sqlLiteral(user.id)}::uuid
        ${expertProfileId ? `or sr.beneficiary_user_id = ${sqlLiteral(user.id)}::uuid or sr.expert_id = ${sqlLiteral(expertProfileId)}::uuid` : ''};
    `),
  ])

  return {
    user,
    orders: orders || [],
    tasks: tasks || [],
    consultingRequests: consultingRequests || [],
    dataServiceRequests: dataServiceRequests || [],
    courseRegistrations: courseRegistrations || [],
    eventRegistrations: eventRegistrations || [],
    expertApplication,
    expertProfile,
    expertBookings: expertBookings || [],
    expertWithdrawals: expertWithdrawals || [],
    settlements: settlements || [],
  }
}

export async function createAdminUser(token, payload = {}) {
  const { actor } = await requireAdmin(token, 'user:write')
  const phone = normalizePhone(payload.phone)
  const wechatId = normalizeWechatId(payload.wechatId || payload.wechat_id)
  if (phone) assertValidPhone(phone)
  if (!phone && !wechatId) {
    const error = new Error('新增用户至少需要手机号或微信号')
    error.statusCode = 400
    error.code = 'invalid_user_identity'
    throw error
  }
  const status = payload.status || 'active'
  if (!userStatuses.has(status)) {
    const error = new Error('用户状态无效')
    error.statusCode = 400
    error.code = 'invalid_user_status'
    throw error
  }
  const created = await queryJson(`
    insert into users (
      phone, wechat_id, display_name, contact_phone, email, organization, title, city, address, industry,
      invoice_title, tax_number, invoice_email,
      membership_level, onboarding_completed, status
    )
    values (
      ${phone ? sqlLiteral(phone) : 'null'},
      ${wechatId ? sqlLiteral(wechatId) : 'null'},
      ${sqlLiteral(payload.displayName || payload.name || '')},
      ${sqlLiteral(payload.contactPhone || payload.contact_phone || '')},
      ${sqlLiteral(payload.email || '')},
      ${sqlLiteral(payload.organization || '')},
      ${sqlLiteral(payload.title || '')},
      ${sqlLiteral(payload.city || '')},
      ${sqlLiteral(payload.address || '')},
      ${sqlLiteral(payload.industry || '')},
      ${sqlLiteral(payload.invoiceTitle || payload.invoice_title || '')},
      ${sqlLiteral(payload.taxNumber || payload.tax_number || '')},
      ${sqlLiteral(payload.invoiceEmail || payload.invoice_email || '')},
      'registered',
      ${payload.onboardingCompleted === false ? 'false' : 'true'},
      ${sqlLiteral(status)}
    )
    returning ${userJson('users')};
  `)
  await auditAdmin(actor, 'user.create', 'user', created.id, created)
  return created
}

export async function updateAdminUser(token, userId, payload = {}) {
  const { actor } = await requireAdmin(token, 'user:write')
  const status = payload.status || 'active'
  if (!userStatuses.has(status)) {
    const error = new Error('用户状态无效')
    error.statusCode = 400
    error.code = 'invalid_user_status'
    throw error
  }
  const phone = normalizePhone(payload.phone)
  const wechatId = normalizeWechatId(payload.wechatId || payload.wechat_id)
  if (phone) assertValidPhone(phone)
  const updated = await queryJson(`
    update users
    set phone = ${phone ? sqlLiteral(phone) : 'null'},
        wechat_id = ${wechatId ? sqlLiteral(wechatId) : 'null'},
        display_name = ${sqlLiteral(payload.displayName || payload.name || '')},
        contact_phone = ${sqlLiteral(payload.contactPhone || payload.contact_phone || '')},
        email = ${sqlLiteral(payload.email || '')},
        organization = ${sqlLiteral(payload.organization || '')},
        title = ${sqlLiteral(payload.title || '')},
        city = ${sqlLiteral(payload.city || '')},
        address = ${sqlLiteral(payload.address || '')},
        industry = ${sqlLiteral(payload.industry || '')},
        invoice_title = ${sqlLiteral(payload.invoiceTitle || payload.invoice_title || '')},
        tax_number = ${sqlLiteral(payload.taxNumber || payload.tax_number || '')},
        invoice_email = ${sqlLiteral(payload.invoiceEmail || payload.invoice_email || '')},
        onboarding_completed = ${payload.onboardingCompleted === false ? 'false' : 'true'},
        status = ${sqlLiteral(status)},
        updated_at = now()
    where id = ${sqlLiteral(userId)}::uuid
       or passport_no = ${sqlLiteral(normalizePassportNo(userId))}
    returning ${userJson('users')};
  `)
  if (!updated) {
    const error = new Error('用户不存在')
    error.statusCode = 404
    error.code = 'user_not_found'
    throw error
  }
  await auditAdmin(actor, 'user.update', 'user', updated.id, updated)
  return updated
}

export async function listProducts(type) {
  return listPublishedProducts(type)
}

export async function getProduct(slug) {
  return findPublishedProduct(slug)
}

const siteStatuses = new Set(['published', 'hidden'])
const aboutSections = new Set([
  'work_steps',
  'service_objects',
  'representative_work',
  'cooperation_modes',
  'honors',
  'partners',
  'team',
])

function normalizeSiteStatus(value) {
  return siteStatuses.has(value) ? value : 'published'
}

function siteAboutItemJson(alias = 'i') {
  return `json_build_object(
    'id', ${alias}.id,
    'section', ${alias}.section,
    'title', ${alias}.title,
    'description', ${alias}.description,
    'imageUrl', ${alias}.image_url,
    'sortOrder', ${alias}.sort_order,
    'status', ${alias}.status,
    'metadata', coalesce(${alias}.metadata, '{}'::jsonb),
    'role', coalesce(${alias}.metadata->>'role', ''),
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function siteAboutItemsSubquery(section, includeHidden) {
  return `coalesce((
    select json_agg(${siteAboutItemJson('i')} order by i.sort_order, i.created_at)
    from site_about_items i
    where i.section = ${sqlLiteral(section)}
      and (${includeHidden ? 'true' : "i.status = 'published'"})
  ), '[]'::json)`
}

function siteConsultingDirectionJson(alias = 'd') {
  return `json_build_object(
    'id', ${alias}.id,
    'slug', ${alias}.slug,
    'title', ${alias}.title,
    'summary', ${alias}.summary,
    'description', ${alias}.description,
    'note', ${alias}.note,
    'imageUrl', ${alias}.image_url,
    'scenarios', coalesce(to_json(${alias}.scenarios), '[]'::json),
    'ownerDepartment', coalesce(${alias}.owner_department, ''),
    'ownerOrgUnitId', ${alias}.owner_org_unit_id,
    'defaultAssigneeId', ${alias}.default_assignee_id,
    'sortOrder', ${alias}.sort_order,
    'status', ${alias}.status,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function siteConsultingCaseJson(alias = 'c') {
  return `json_build_object(
    'id', ${alias}.id,
    'type', ${alias}.type,
    'title', ${alias}.title,
    'summary', ${alias}.summary,
    'imageUrl', ${alias}.image_url,
    'sortOrder', ${alias}.sort_order,
    'status', ${alias}.status,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function siteConsultingStepJson(alias = 's') {
  return `json_build_object(
    'id', ${alias}.id,
    'title', ${alias}.title,
    'description', ${alias}.description,
    'sortOrder', ${alias}.sort_order,
    'status', ${alias}.status,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function consultingOptionSetJson(alias = 's', includeHidden = false) {
  return `json_build_object(
    'code', ${alias}.code,
    'name', ${alias}.name,
    'description', ${alias}.description,
    'sortOrder', ${alias}.sort_order,
    'status', ${alias}.status,
    'options', coalesce((
      select json_agg(json_build_object(
        'id', o.id,
        'setCode', o.set_code,
        'code', o.code,
        'label', o.label,
        'description', o.description,
        'sortOrder', o.sort_order,
        'status', o.status,
        'metadata', coalesce(o.metadata, '{}'::jsonb),
        'createdAt', o.created_at,
        'updatedAt', o.updated_at
      ) order by o.sort_order, o.created_at)
      from consulting_options o
      where o.set_code = ${alias}.code
        and (${includeHidden ? 'true' : "o.status = 'active'"})
    ), '[]'::json),
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function consultingOptionSetsSubquery(includeHidden = false) {
  return `coalesce((
    select json_agg(${consultingOptionSetJson('cos', includeHidden)} order by cos.sort_order, cos.created_at)
    from consulting_option_sets cos
    where ${includeHidden ? 'true' : "cos.status = 'active'"}
  ), '[]'::json)`
}

function siteHomeFeaturedExpertsSubquery(includeHidden) {
  return `coalesce((
    select json_agg(featured.expert order by featured.sort_order)
    from (
      select ${expertProfileJson('e')} as expert,
        fe.sort_order
      from site_home_featured_experts fe
      join expert_profiles e on e.id = fe.expert_id
      where e.deleted_at is null
        and (${includeHidden ? 'true' : "e.status = 'published'"})
      order by fe.sort_order, e.updated_at desc
      limit 4
    ) featured
  ), '[]'::json)`
}

function sitePageBlockItemJson(alias = 'i') {
  return `json_build_object(
    'id', ${alias}.id,
    'itemKey', ${alias}.item_key,
    'title', ${alias}.title,
    'subtitle', ${alias}.subtitle,
    'description', ${alias}.description,
    'imageUrl', ${alias}.image_url,
    'href', ${alias}.href,
    'ctaLabel', ${alias}.cta_label,
    'status', ${alias}.status,
    'sortOrder', ${alias}.sort_order,
    'metadata', coalesce(${alias}.metadata, '{}'::jsonb),
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function sitePageBlockJson(alias = 'b', includeHidden = false) {
  return `json_build_object(
    'id', ${alias}.id,
    'pageKey', ${alias}.page_key,
    'blockKey', ${alias}.block_key,
    'title', ${alias}.title,
    'subtitle', ${alias}.subtitle,
    'body', ${alias}.body,
    'layout', ${alias}.layout,
    'status', ${alias}.status,
    'sortOrder', ${alias}.sort_order,
    'metadata', coalesce(${alias}.metadata, '{}'::jsonb),
    'items', coalesce((
      select json_agg(${sitePageBlockItemJson('i')} order by i.sort_order, i.created_at)
      from site_page_block_items i
      where i.block_id = ${alias}.id
        and (${includeHidden ? 'true' : "i.status = 'published'"})
    ), '[]'::json),
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function sitePageBlocksSubquery(pageKey, includeHidden) {
  return `coalesce((
    select json_agg(${sitePageBlockJson('b', includeHidden)} order by b.sort_order, b.block_key)
    from site_page_blocks b
    where b.page_key = ${sqlLiteral(pageKey)}
      and (${includeHidden ? 'true' : "b.status = 'published'"})
  ), '[]'::json)`
}

const recommendationSlotDefinitions = [
  { placementKey: 'home.news', sourceType: 'news', slots: [['primary', 10], ['secondary_1', 20], ['secondary_2', 30]] },
  { placementKey: 'home.courses', sourceType: 'course', slots: [['slot_1', 10], ['slot_2', 20], ['slot_3', 30]] },
  { placementKey: 'home.events', sourceType: 'event', slots: [['slot_1', 10], ['slot_2', 20], ['slot_3', 30], ['slot_4', 40]] },
  { placementKey: 'home.experts', sourceType: 'expert', slots: [['slot_1', 10], ['slot_2', 20], ['slot_3', 30], ['slot_4', 40]] },
  { placementKey: 'news.carousel', sourceType: 'news', slots: [['slide_1', 10], ['slide_2', 20], ['slide_3', 30]] },
  { placementKey: 'courses.carousel', sourceType: 'course', slots: [['slide_1', 10], ['slide_2', 20], ['slide_3', 30], ['slide_4', 40], ['slide_5', 50]] },
  { placementKey: 'events.carousel', sourceType: 'event', slots: [['slide_1', 10], ['slide_2', 20], ['slide_3', 30], ['slide_4', 40], ['slide_5', 50]] },
]

const recommendationDefinitionBySlot = new Map(
  recommendationSlotDefinitions.flatMap((placement) => placement.slots.map(([slotKey, sortOrder]) => [
    `${placement.placementKey}:${slotKey}`,
    { placementKey: placement.placementKey, slotKey, sourceType: placement.sourceType, sortOrder },
  ])),
)
const recommendationPlacementKeys = recommendationSlotDefinitions.map((item) => item.placementKey)
const homeRecommendationPlacements = ['home.news', 'home.courses', 'home.events', 'home.experts']
const recommendationStatuses = new Set(['active', 'hidden'])
const recommendationSourceTypes = new Set(['news', 'course', 'event', 'expert'])

function recommendationDefaultRows(placements = recommendationPlacementKeys) {
  const placementSet = new Set(placements)
  return recommendationSlotDefinitions
    .filter((placement) => placementSet.has(placement.placementKey))
    .flatMap((placement) => placement.slots.map(([slotKey, sortOrder]) => ({
      id: null,
      placementKey: placement.placementKey,
      slotKey,
      sourceType: placement.sourceType,
      sourceId: '',
      status: 'hidden',
      sortOrder,
      titleOverride: '',
      subtitleOverride: '',
      imageUrlOverride: '',
      ctaLabelOverride: '',
      hrefOverride: '',
      metadata: {},
      createdAt: null,
      updatedAt: null,
    })))
}

function normalizeRecommendationPlacements(value) {
  const raw = Array.isArray(value)
    ? value
    : String(value || '')
      .split(',')
      .map((item) => item.trim())
      .filter(Boolean)
  const allowed = new Set(recommendationPlacementKeys)
  const selected = raw.filter((item) => allowed.has(item))
  return selected.length ? [...new Set(selected)] : recommendationPlacementKeys
}

function normalizeRecommendationStatus(value) {
  return recommendationStatuses.has(value) ? value : 'active'
}

function recommendationSourceHref(sourceType, source = {}) {
  if (!source?.slug && !source?.id) return ''
  if (sourceType === 'news') return `/news/${source.slug || source.id}`
  if (sourceType === 'course') return `/courses/${source.slug || source.id}`
  if (sourceType === 'event') return `/events/${source.slug || source.id}`
  if (sourceType === 'expert') return `/experts/${source.slug || source.id}`
  return ''
}

function recommendationSourceImage(sourceType, source = {}) {
  if (sourceType === 'expert') return source.photoUrl || ''
  return source.coverUrl || ''
}

function recommendationSourceTitle(sourceType, source = {}) {
  if (sourceType === 'expert') return source.name || ''
  return source.title || ''
}

function recommendationSourceSubtitle(sourceType, source = {}) {
  if (sourceType === 'expert') return [source.title, source.organization].filter(Boolean).join(' · ')
  if (sourceType === 'course') return [source.instructor, source.lessonCount ? `${source.lessonCount} 课时` : ''].filter(Boolean).join(' · ')
  if (sourceType === 'event') return source.location || (source.isOnline ? '线上活动' : '')
  return source.summary || source.category || ''
}

function recommendationSourceBadge(sourceType, source = {}) {
  if (sourceType === 'news') return source.category || ''
  if (sourceType === 'course') return source.direction || source.courseFormat || ''
  if (sourceType === 'event') return source.topic || source.eventFormat || ''
  if (sourceType === 'expert') return Array.isArray(source.directions) ? source.directions[0] || '' : ''
  return ''
}

function recommendationSourceStatus(sourceType, source = {}) {
  if (sourceType === 'course' || sourceType === 'event') return source.status || source.productStatus || ''
  return source.status || ''
}

function recommendationVisibilityWarnings(sourceType, source = {}) {
  const warnings = []
  if (!source) return ['内容不存在或已删除']
  if (sourceType === 'news' && source.status !== 'published') warnings.push('资讯未发布，用户端不会展示')
  if (sourceType === 'course' && (source.productStatus !== 'published' || source.status !== 'published')) warnings.push('课程未发布，用户端不会展示')
  if (sourceType === 'event') {
    if (source.productStatus !== 'published' || source.status !== 'published') warnings.push('活动未发布，用户端不会展示')
    if (source.endsAt && new Date(source.endsAt).getTime() < Date.now()) warnings.push('活动已结束，用户端轮播不会展示')
  }
  if (sourceType === 'expert' && source.status !== 'published') warnings.push('专家未发布，用户端不会展示')
  return warnings
}

function recommendationCandidateFromSource(sourceType, source = {}) {
  return {
    id: String(source.id || source.productId || ''),
    sourceType,
    title: recommendationSourceTitle(sourceType, source),
    subtitle: recommendationSourceSubtitle(sourceType, source),
    imageUrl: recommendationSourceImage(sourceType, source),
    href: recommendationSourceHref(sourceType, source),
    status: recommendationSourceStatus(sourceType, source),
    badge: recommendationSourceBadge(sourceType, source),
    startsAt: source.startsAt || null,
    endsAt: source.endsAt || null,
    priceAmount: source.priceAmount || 0,
    source,
  }
}

function resolvedRecommendationSlot(row, source, warnings = []) {
  const sourceType = row.sourceType
  const candidate = source ? recommendationCandidateFromSource(sourceType, source) : null
  return {
    ...row,
    title: row.titleOverride || candidate?.title || '',
    subtitle: row.subtitleOverride || candidate?.subtitle || '',
    imageUrl: row.imageUrlOverride || candidate?.imageUrl || '',
    href: row.hrefOverride || candidate?.href || '',
    ctaLabel: row.ctaLabelOverride || (sourceType === 'news' ? '阅读全文' : sourceType === 'expert' ? '预约咨询' : '查看详情'),
    badge: candidate?.badge || '',
    startsAt: candidate?.startsAt || null,
    endsAt: candidate?.endsAt || null,
    priceAmount: candidate?.priceAmount || 0,
    source: candidate?.source || null,
    warnings,
  }
}

async function readRecommendationSlotRows(placements = recommendationPlacementKeys) {
  try {
    const placementSql = placements.length
      ? `where s.placement_key in (${placements.map(sqlLiteral).join(',')})`
      : ''
    return await queryJson(`
      select coalesce(json_agg(json_build_object(
        'id', s.id,
        'placementKey', s.placement_key,
        'slotKey', s.slot_key,
        'sourceType', s.source_type,
        'sourceId', s.source_id,
        'status', s.status,
        'sortOrder', s.sort_order,
        'titleOverride', s.title_override,
        'subtitleOverride', s.subtitle_override,
        'imageUrlOverride', s.image_url_override,
        'ctaLabelOverride', s.cta_label_override,
        'hrefOverride', s.href_override,
        'metadata', coalesce(s.metadata, '{}'::jsonb),
        'createdAt', s.created_at,
        'updatedAt', s.updated_at
      ) order by s.placement_key, s.sort_order, s.slot_key), '[]'::json)
      from site_recommendation_slots s
      ${placementSql};
    `)
  } catch (error) {
    if (String(error?.message || '').includes('site_recommendation_slots')) return []
    throw error
  }
}

function mergeRecommendationDefaultRows(rows = [], placements = recommendationPlacementKeys) {
  const byKey = new Map((Array.isArray(rows) ? rows : []).map((row) => [`${row.placementKey}:${row.slotKey}`, row]))
  return recommendationDefaultRows(placements).map((fallback) => ({
    ...fallback,
    ...(byKey.get(`${fallback.placementKey}:${fallback.slotKey}`) || {}),
  }))
}

async function findRecommendationSource(sourceType, sourceId, includeHidden = false) {
  const key = String(sourceId || '').trim()
  if (!key || !recommendationSourceTypes.has(sourceType)) return null
  if (sourceType === 'news') {
    return queryJson(`
      select ${newsArticleJson('n')}
      from news_articles n
      where (n.id::text = ${sqlLiteral(key)} or n.slug = ${sqlLiteral(key)})
        and (${includeHidden ? 'true' : "n.status = 'published'"})
      limit 1;
    `)
  }
  if (sourceType === 'course') {
    return queryJson(`
      select ${courseProductJson('p', 'd')}
      from products p
      join course_product_details d on d.product_id = p.id
      where p.type = 'course'
        and (p.id = ${sqlLiteral(key)} or p.slug = ${sqlLiteral(key)})
        and (${includeHidden ? 'true' : "p.status = 'published' and d.status = 'published'"})
      limit 1;
    `)
  }
  if (sourceType === 'event') {
    return queryJson(`
      select ${eventProductJson('p', 'd')}
      from products p
      join event_product_details d on d.product_id = p.id
      where p.type = 'event'
        and (p.id = ${sqlLiteral(key)} or p.slug = ${sqlLiteral(key)})
        and (${includeHidden ? 'true' : "p.status = 'published' and d.status = 'published' and (d.ends_at is null or d.ends_at >= now())"})
      limit 1;
    `)
  }
  if (sourceType === 'expert') {
    return queryJson(`
      select ${expertProfileJson('e')}
      from expert_profiles e
      where (e.id::text = ${sqlLiteral(key)} or e.slug = ${sqlLiteral(key)})
        and e.deleted_at is null
        and (${includeHidden ? 'true' : "e.status = 'published'"})
      limit 1;
    `)
  }
  return null
}

export async function getSiteRecommendations(placementsInput = '', options = {}) {
  const includeHidden = Boolean(options.includeHidden)
  const placements = normalizeRecommendationPlacements(placementsInput)
  const dbRows = await readRecommendationSlotRows(placements)
  const rows = includeHidden ? mergeRecommendationDefaultRows(dbRows, placements) : dbRows
  const items = []

  for (const row of rows) {
    if (!row || !placements.includes(row.placementKey)) continue
    if (!includeHidden && row.status !== 'active') continue
    if (!row.sourceId) {
      if (includeHidden) items.push(resolvedRecommendationSlot(row, null, ['未选择内容']))
      continue
    }
    const source = await findRecommendationSource(row.sourceType, row.sourceId, includeHidden)
    if (!source) {
      if (includeHidden) items.push(resolvedRecommendationSlot(row, null, ['内容不存在、已删除或当前不可见']))
      continue
    }
    const warnings = includeHidden ? recommendationVisibilityWarnings(row.sourceType, source) : []
    items.push(resolvedRecommendationSlot(row, source, warnings))
  }

  const grouped = Object.fromEntries(placements.map((placement) => [placement, []]))
  for (const item of items) {
    if (!grouped[item.placementKey]) grouped[item.placementKey] = []
    grouped[item.placementKey].push(item)
  }
  return { placements: grouped, items }
}

export async function listAdminRecommendationSlots(token, filters = {}) {
  await requireAdminAny(token, ['site_recommendation:read', 'site:read'])
  return getSiteRecommendations(filters.placements || filters.placement || '', { includeHidden: true })
}

export async function listAdminRecommendationCandidates(token, filters = {}) {
  await requireAdminAny(token, ['site_recommendation:read', 'site:read'])
  const sourceType = String(filters.sourceType || filters.source_type || '').trim()
  if (!recommendationSourceTypes.has(sourceType)) {
    const error = new Error('推荐内容类型无效')
    error.statusCode = 400
    error.code = 'invalid_recommendation_source_type'
    throw error
  }
  const keyword = String(filters.keyword || '').trim()
  const status = String(filters.status || 'published').trim()
  const statusFilter = status === 'all' || status === '' ? 'true' : null
  let items = []
  if (sourceType === 'news') {
    items = await queryJson(`
      select coalesce(json_agg(${newsArticleJson('n')} order by n.published_at desc nulls last, n.updated_at desc), '[]'::json)
      from news_articles n
      where (${statusFilter || `n.status = ${sqlLiteral(status)}`})
        and (${sqlLiteral(keyword)} = '' or n.title ilike '%' || ${sqlLiteral(keyword)} || '%' or n.summary ilike '%' || ${sqlLiteral(keyword)} || '%' or n.category ilike '%' || ${sqlLiteral(keyword)} || '%')
      limit 80;
    `)
  } else if (sourceType === 'course') {
    items = await queryJson(`
      select coalesce(json_agg(${courseProductJson('p', 'd')} order by d.sort_order, p.updated_at desc), '[]'::json)
      from products p
      join course_product_details d on d.product_id = p.id
      where p.type = 'course'
        and (${statusFilter || `(p.status = ${sqlLiteral(status)} and d.status = ${sqlLiteral(status)})`})
        and (${sqlLiteral(keyword)} = '' or p.title ilike '%' || ${sqlLiteral(keyword)} || '%' or p.summary ilike '%' || ${sqlLiteral(keyword)} || '%' or d.direction ilike '%' || ${sqlLiteral(keyword)} || '%' or d.instructor ilike '%' || ${sqlLiteral(keyword)} || '%')
      limit 80;
    `)
  } else if (sourceType === 'event') {
    items = await queryJson(`
      select coalesce(json_agg(${eventProductJson('p', 'd')} order by d.starts_at nulls last, d.sort_order, p.updated_at desc), '[]'::json)
      from products p
      join event_product_details d on d.product_id = p.id
      where p.type = 'event'
        and d.status <> 'deleted'
        and (${statusFilter || `(p.status = ${sqlLiteral(status)} and d.status = ${sqlLiteral(status)})`})
        and (${sqlLiteral(keyword)} = '' or p.title ilike '%' || ${sqlLiteral(keyword)} || '%' or p.summary ilike '%' || ${sqlLiteral(keyword)} || '%' or d.topic ilike '%' || ${sqlLiteral(keyword)} || '%' or d.location ilike '%' || ${sqlLiteral(keyword)} || '%')
      limit 80;
    `)
  } else if (sourceType === 'expert') {
    items = await queryJson(`
      select coalesce(json_agg(${expertProfileJson('e')} order by e.updated_at desc), '[]'::json)
      from expert_profiles e
      where e.deleted_at is null
        and (${statusFilter || `e.status = ${sqlLiteral(status)}`})
        and (${sqlLiteral(keyword)} = '' or e.name ilike '%' || ${sqlLiteral(keyword)} || '%' or e.organization ilike '%' || ${sqlLiteral(keyword)} || '%' or e.title ilike '%' || ${sqlLiteral(keyword)} || '%')
      limit 80;
    `)
  }
  return { items: (items || []).map((source) => recommendationCandidateFromSource(sourceType, source)) }
}

export async function updateAdminRecommendationSlots(token, payload = {}) {
  const { actor } = await requireAdminAny(token, ['site_recommendation:write', 'site:write'])
  const slots = Array.isArray(payload) ? payload : Array.isArray(payload.slots) ? payload.slots : []
  if (!slots.length) return listAdminRecommendationSlots(token)
  const normalized = []
  const seenByPlacement = new Map()

  for (const [index, slot] of slots.entries()) {
    const placementKey = String(slot.placementKey || slot.placement_key || '').trim()
    const slotKey = String(slot.slotKey || slot.slot_key || '').trim()
    const definition = recommendationDefinitionBySlot.get(`${placementKey}:${slotKey}`)
    if (!definition) {
      const error = new Error(`推荐位不存在：${placementKey}/${slotKey}`)
      error.statusCode = 400
      error.code = 'invalid_recommendation_slot'
      throw error
    }
    const sourceType = String(slot.sourceType || slot.source_type || definition.sourceType).trim()
    if (sourceType !== definition.sourceType) {
      const error = new Error('推荐位内容类型不匹配')
      error.statusCode = 400
      error.code = 'recommendation_source_type_mismatch'
      throw error
    }
    const sourceId = String(slot.sourceId || slot.source_id || '').trim()
    const status = normalizeRecommendationStatus(slot.status)
    if (status === 'active' && !sourceId) {
      const error = new Error('显示中的推荐位必须选择内容')
      error.statusCode = 400
      error.code = 'recommendation_source_required'
      throw error
    }
    if (sourceId) {
      const source = await findRecommendationSource(sourceType, sourceId, true)
      if (!source) {
        const error = new Error('推荐内容不存在')
        error.statusCode = 404
        error.code = 'recommendation_source_not_found'
        throw error
      }
      const duplicateKey = `${sourceType}:${sourceId}`
      const seen = seenByPlacement.get(placementKey) || new Set()
      if (seen.has(duplicateKey)) {
        const error = new Error('同一个推荐区不能重复选择同一内容')
        error.statusCode = 409
        error.code = 'duplicate_recommendation_source'
        throw error
      }
      seen.add(duplicateKey)
      seenByPlacement.set(placementKey, seen)
    }
    normalized.push({
      ...definition,
      sourceId,
      status,
      sortOrder: toSafeInteger(slot.sortOrder ?? slot.sort_order, definition.sortOrder || (index + 1) * 10),
      titleOverride: String(slot.titleOverride ?? slot.title_override ?? '').trim(),
      subtitleOverride: String(slot.subtitleOverride ?? slot.subtitle_override ?? '').trim(),
      imageUrlOverride: normalizeManagedAssetUrl(slot.imageUrlOverride ?? slot.image_url_override ?? '', '推荐位覆盖图片'),
      ctaLabelOverride: String(slot.ctaLabelOverride ?? slot.cta_label_override ?? '').trim(),
      hrefOverride: String(slot.hrefOverride ?? slot.href_override ?? '').trim(),
      metadata: slot.metadata && typeof slot.metadata === 'object' ? slot.metadata : {},
    })
  }

  for (const slot of normalized) {
    await executeSql(`
      insert into site_recommendation_slots (
        placement_key, slot_key, source_type, source_id, status, sort_order,
        title_override, subtitle_override, image_url_override, cta_label_override, href_override, metadata
      )
      values (
        ${sqlLiteral(slot.placementKey)},
        ${sqlLiteral(slot.slotKey)},
        ${sqlLiteral(slot.sourceType)},
        ${sqlLiteral(slot.sourceId)},
        ${sqlLiteral(slot.status)},
        ${toSafeInteger(slot.sortOrder, 100)},
        ${sqlLiteral(slot.titleOverride)},
        ${sqlLiteral(slot.subtitleOverride)},
        ${sqlLiteral(slot.imageUrlOverride)},
        ${sqlLiteral(slot.ctaLabelOverride)},
        ${sqlLiteral(slot.hrefOverride)},
        ${sqlJsonb(slot.metadata)}
      )
      on conflict (placement_key, slot_key) do update set
        source_type = excluded.source_type,
        source_id = excluded.source_id,
        status = excluded.status,
        sort_order = excluded.sort_order,
        title_override = excluded.title_override,
        subtitle_override = excluded.subtitle_override,
        image_url_override = excluded.image_url_override,
        cta_label_override = excluded.cta_label_override,
        href_override = excluded.href_override,
        metadata = excluded.metadata,
        updated_at = now();
    `)
  }

  const updated = await listAdminRecommendationSlots(token)
  await auditAdmin(actor, 'site_recommendation.update', 'site_recommendation_slot', 'batch', { slots: normalized })
  return updated
}

async function saveSitePageBlocks(pageKey, blocksInput = [], options = {}) {
  const blocks = Array.isArray(blocksInput) ? blocksInput : []
  const allowedBlockKeys = Array.isArray(options.allowedBlockKeys) && options.allowedBlockKeys.length
    ? new Set(options.allowedBlockKeys)
    : null
  const pruneBlocks = options.pruneBlocks !== false
  const blockKeys = []
  for (const [blockIndex, block] of blocks.entries()) {
    const blockKey = String(block.blockKey || block.block_key || block.key || '').trim()
    if (!blockKey) continue
    if (allowedBlockKeys && !allowedBlockKeys.has(blockKey)) continue
    const blockId = isUuid(block.id) ? String(block.id) : randomUUID()
    blockKeys.push(blockKey)
    await executeSql(`
      insert into site_page_blocks (
        id, page_key, block_key, title, subtitle, body, layout, status, sort_order, metadata
      )
      values (
        ${sqlLiteral(blockId)}::uuid,
        ${sqlLiteral(pageKey)},
        ${sqlLiteral(blockKey)},
        ${sqlLiteral(block.title || '')},
        ${sqlLiteral(block.subtitle || '')},
        ${sqlLiteral(block.body || '')},
        ${sqlLiteral(block.layout || '')},
        ${sqlLiteral(normalizeSiteStatus(block.status))},
        ${toSafeInteger(block.sortOrder ?? block.sort_order, (blockIndex + 1) * 10)},
        ${sqlJsonb(block.metadata || {})}
      )
      on conflict (page_key, block_key) do update set
        title = excluded.title,
        subtitle = excluded.subtitle,
        body = excluded.body,
        layout = excluded.layout,
        status = excluded.status,
        sort_order = excluded.sort_order,
        metadata = excluded.metadata,
        updated_at = now()
      returning id;
    `)
    const savedBlock = await queryJson(`
      select json_build_object('id', id)
      from site_page_blocks
      where page_key = ${sqlLiteral(pageKey)}
        and block_key = ${sqlLiteral(blockKey)}
      limit 1;
    `)
    const savedBlockId = savedBlock?.id || blockId
    const items = Array.isArray(block.items) ? block.items : []
    const itemKeys = []
    for (const [itemIndex, item] of items.entries()) {
      const itemKey = String(item.itemKey || item.item_key || item.key || item.title || `item-${itemIndex + 1}`).trim()
      if (!itemKey) continue
      itemKeys.push(itemKey)
      await executeSql(`
        insert into site_page_block_items (
          block_id, item_key, title, subtitle, description, image_url,
          href, cta_label, status, sort_order, metadata
        )
        values (
          ${sqlLiteral(savedBlockId)}::uuid,
          ${sqlLiteral(itemKey)},
          ${sqlLiteral(item.title || '')},
          ${sqlLiteral(item.subtitle || '')},
          ${sqlLiteral(item.description || item.desc || '')},
          ${sqlLiteral(normalizeManagedAssetUrl(item.imageUrl || item.image_url || '', '页面区块图片'))},
          ${sqlLiteral(item.href || '')},
          ${sqlLiteral(item.ctaLabel || item.cta_label || item.label || '')},
          ${sqlLiteral(normalizeSiteStatus(item.status))},
          ${toSafeInteger(item.sortOrder ?? item.sort_order, (itemIndex + 1) * 10)},
          ${sqlJsonb(item.metadata || {})}
        )
        on conflict (block_id, item_key) do update set
          title = excluded.title,
          subtitle = excluded.subtitle,
          description = excluded.description,
          image_url = excluded.image_url,
          href = excluded.href,
          cta_label = excluded.cta_label,
          status = excluded.status,
          sort_order = excluded.sort_order,
          metadata = excluded.metadata,
          updated_at = now();
      `)
    }
    await executeSql(itemKeys.length ? `
      delete from site_page_block_items
      where block_id = ${sqlLiteral(savedBlockId)}::uuid
        and item_key not in (${itemKeys.map(sqlLiteral).join(',')});
    ` : `
      delete from site_page_block_items
      where block_id = ${sqlLiteral(savedBlockId)}::uuid;
    `)
  }
  if (!pruneBlocks) return
  await executeSql(blockKeys.length ? `
    delete from site_page_blocks
    where page_key = ${sqlLiteral(pageKey)}
      and block_key not in (${blockKeys.map(sqlLiteral).join(',')});
  ` : `
    delete from site_page_blocks
    where page_key = ${sqlLiteral(pageKey)};
  `)
}

const dataServicePageBlockKeys = ['data_service_page', 'data_service_apply']
const dataServiceHeroAssetKeys = ['data_service_hero_1', 'data_service_hero_2', 'data_service_hero_3']

function defaultDataServicePageBlocks() {
  return [
    {
      blockKey: 'data_service_page',
      title: '数据服务',
      subtitle: '提交数据主题、地区范围、口径和交付格式，业务员沟通后报价交付。',
      body: '面向政府、园区、企业和研究机构提供数据检索、资料整理、指标口径梳理、行业样本和分析交付服务。',
      layout: 'data_service_page',
      status: 'published',
      sortOrder: 120,
      metadata: {
        casesTitle: '服务案例',
        casesDescription: '案例只用于理解服务边界，最终范围与报价以需求沟通为准。',
      },
      items: [],
    },
    {
      blockKey: 'data_service_apply',
      title: '我要找数据',
      subtitle: '提交后进入需求受理，沟通确认后再报价。',
      body: '说明你要找的数据主题、地区、时间范围、字段口径和期望交付，我们会安排人员联系确认。',
      layout: 'cta',
      status: 'published',
      sortOrder: 130,
      metadata: {},
      items: [
        {
          itemKey: 'primary_cta',
          title: '数据服务申请',
          subtitle: '',
          description: '',
          imageUrl: '',
          href: '/data/apply',
          ctaLabel: '提交需求',
          status: 'published',
          sortOrder: 10,
          metadata: {},
        },
      ],
    },
  ]
}

function defaultDataServiceHeroAssets() {
  return dataServiceHeroAssetKeys.map((key, index) => ({
    key,
    label: `数据服务主视觉 ${index + 1}`,
    title: `数据服务主视觉 ${index + 1}`,
    description: '',
    imageUrl: '',
    sortOrder: (index + 1) * 10,
    status: index === 0 ? 'published' : 'hidden',
  }))
}

function mergeDataServicePageBlocks(blocks = []) {
  const input = Array.isArray(blocks) ? blocks : []
  const byKey = new Map(input.map((block) => [block.blockKey || block.block_key, block]))
  return defaultDataServicePageBlocks().map((fallback) => {
    const current = byKey.get(fallback.blockKey)
    if (!current) return fallback
    const fallbackItems = new Map((fallback.items || []).map((item) => [item.itemKey, item]))
    const currentItems = Array.isArray(current.items) ? current.items : []
    return {
      ...fallback,
      ...current,
      blockKey: fallback.blockKey,
      items: currentItems.length
        ? currentItems.map((item) => ({ ...(fallbackItems.get(item.itemKey) || {}), ...item }))
        : fallback.items,
    }
  })
}

function mergeDataServiceHeroAssets(assets = []) {
  const input = Array.isArray(assets) ? assets : []
  const byKey = new Map(input.map((asset) => [asset.key || asset.assetKey || asset.asset_key, asset]))
  return defaultDataServiceHeroAssets().map((fallback) => ({
    ...fallback,
    ...(byKey.get(fallback.key) || {}),
    key: fallback.key,
  }))
}

async function getDataServicePageConfig(includeHidden = false) {
  const blocks = await queryJson(`
    select coalesce((
      select json_agg(${sitePageBlockJson('b', includeHidden)} order by b.sort_order, b.block_key)
      from site_page_blocks b
      where b.page_key = 'home'
        and b.block_key in (${dataServicePageBlockKeys.map(sqlLiteral).join(',')})
        and (${includeHidden ? 'true' : "b.status = 'published'"})
    ), '[]'::json);
  `)
  const assets = await queryJson(`
    select coalesce((
      select json_agg(json_build_object(
        'key', asset_key,
        'label', label,
        'title', title,
        'description', description,
        'imageUrl', image_url,
        'sortOrder', sort_order,
        'status', status,
        'createdAt', created_at,
        'updatedAt', updated_at
      ) order by sort_order, asset_key)
      from site_home_assets
      where asset_key in (${dataServiceHeroAssetKeys.map(sqlLiteral).join(',')})
        and (${includeHidden ? 'true' : "status = 'published'"})
    ), '[]'::json);
  `)
  return {
    blocks: mergeDataServicePageBlocks(blocks),
    assets: mergeDataServiceHeroAssets(assets),
  }
}

export async function getDataServicePage() {
  return getDataServicePageConfig(false)
}

export async function getAdminDataServicePage(token) {
  await requireAdminAny(token, ['data_service_page:read', 'data_page:read', 'site:read'])
  return getDataServicePageConfig(true)
}

export async function updateAdminDataServicePage(token, payload = {}) {
  const { actor } = await requireAdminAny(token, ['data_service_page:write', 'data_page:write', 'site:write'])
  const blocks = mergeDataServicePageBlocks(payload.blocks || [])
  const assets = mergeDataServiceHeroAssets(payload.assets || [])
  await saveSitePageBlocks('home', blocks, {
    allowedBlockKeys: dataServicePageBlockKeys,
    pruneBlocks: false,
  })
  for (const [index, asset] of assets.entries()) {
    await executeSql(`
      insert into site_home_assets (asset_key, label, title, description, image_url, sort_order, status)
      values (
        ${sqlLiteral(asset.key)},
        ${sqlLiteral(asset.label || '')},
        ${sqlLiteral(asset.title || '')},
        ${sqlLiteral(asset.description || '')},
        ${sqlLiteral(asset.imageUrl || asset.image_url || '')},
        ${toSafeInteger(asset.sortOrder ?? asset.sort_order, (index + 1) * 10)},
        ${sqlLiteral(normalizeSiteStatus(asset.status))}
      )
      on conflict (asset_key) do update set
        label = excluded.label,
        title = excluded.title,
        description = excluded.description,
        image_url = excluded.image_url,
        sort_order = excluded.sort_order,
        status = excluded.status,
        updated_at = now();
    `)
  }
  const updated = await getAdminDataServicePage(token)
  await auditAdmin(actor, 'data_service_page.update', 'data_service_page', 'home', updated)
  return updated
}

export async function getSiteHome(options = {}) {
  const includeHidden = Boolean(options.includeHidden)
  const home = await queryJson(`
    select json_build_object(
      'slides', coalesce((
        select json_agg(json_build_object(
          'id', id,
          'title', title,
          'imageUrl', image_url,
          'sortOrder', sort_order,
          'status', status,
          'createdAt', created_at,
          'updatedAt', updated_at
        ) order by sort_order, created_at)
        from site_home_slides
        where ${includeHidden ? 'true' : "status = 'published'"}
      ), '[]'::json),
      'loginSlides', coalesce((
        select json_agg(json_build_object(
          'id', id,
          'title', title,
          'imageUrl', image_url,
          'sortOrder', sort_order,
          'status', status,
          'createdAt', created_at,
          'updatedAt', updated_at
        ) order by sort_order, created_at)
        from site_login_slides
        where ${includeHidden ? 'true' : "status = 'published' and image_url <> ''"}
      ), '[]'::json),
      'assets', coalesce((
        select json_agg(json_build_object(
          'key', asset_key,
          'label', label,
          'title', title,
          'description', description,
          'imageUrl', image_url,
          'sortOrder', sort_order,
          'status', status,
          'createdAt', created_at,
          'updatedAt', updated_at
        ) order by sort_order, asset_key)
        from site_home_assets
        where ${includeHidden ? 'true' : "status = 'published'"}
      ), '[]'::json)
      ,
      'featuredExperts', ${siteHomeFeaturedExpertsSubquery(includeHidden)},
      'blocks', ${sitePageBlocksSubquery('home', includeHidden)}
    );
  `)
  const recommendations = await getSiteRecommendations(homeRecommendationPlacements, { includeHidden })
  return {
    ...home,
    recommendations: {
      news: recommendations.placements['home.news'] || [],
      courses: recommendations.placements['home.courses'] || [],
      events: recommendations.placements['home.events'] || [],
      experts: recommendations.placements['home.experts'] || [],
    },
  }
}

export async function updateAdminSiteHome(token, payload = {}) {
  await requireAdmin(token, 'site:write')
  const slides = Array.isArray(payload.slides) ? payload.slides : []
  const publishedCount = slides.filter((slide) => normalizeSiteStatus(slide.status) === 'published').length
  if (publishedCount < 1 || publishedCount > 5) {
    const error = new Error('首页轮播发布数量必须保持 1 到 5 张')
    error.statusCode = 400
    error.code = 'invalid_home_slide_count'
    throw error
  }

  const slideIds = []
  for (const [index, slide] of slides.entries()) {
    const id = String(slide.id || `home-slide-${randomUUID()}`).trim()
    slideIds.push(id)
    await executeSql(`
      insert into site_home_slides (id, title, image_url, sort_order, status)
      values (
          ${sqlLiteral(id)},
          ${sqlLiteral(slide.title || '')},
          ${sqlLiteral(normalizeManagedAssetUrl(slide.imageUrl || '', '首页轮播图'))},
          ${toSafeInteger(slide.sortOrder, (index + 1) * 10)},
          ${sqlLiteral(normalizeSiteStatus(slide.status))}
      )
      on conflict (id) do update set
        title = excluded.title,
        image_url = excluded.image_url,
        sort_order = excluded.sort_order,
        status = excluded.status,
        updated_at = now();
    `)
  }
  await executeSql(`
    delete from site_home_slides
    where id not in (${slideIds.map(sqlLiteral).join(',')});
  `)

  const loginSlides = Array.isArray(payload.loginSlides) ? payload.loginSlides : []
  if (loginSlides.length > 3) {
    const error = new Error('登录页轮播最多发布 3 张图片')
    error.statusCode = 400
    error.code = 'invalid_login_slide_count'
    throw error
  }

  const loginSlideIds = []
  for (const [index, slide] of loginSlides.entries()) {
    const id = String(slide.id || `login-slide-${randomUUID()}`).trim()
    loginSlideIds.push(id)
    await executeSql(`
      insert into site_login_slides (id, title, image_url, sort_order, status)
      values (
          ${sqlLiteral(id)},
          ${sqlLiteral(slide.title || '')},
          ${sqlLiteral(normalizeManagedAssetUrl(slide.imageUrl || '', '登录轮播图'))},
          ${toSafeInteger(slide.sortOrder, (index + 1) * 10)},
          ${sqlLiteral(normalizeSiteStatus(slide.status))}
      )
      on conflict (id) do update set
        title = excluded.title,
        image_url = excluded.image_url,
        sort_order = excluded.sort_order,
        status = excluded.status,
        updated_at = now();
    `)
  }
  await executeSql(loginSlideIds.length
    ? `
      delete from site_login_slides
      where id not in (${loginSlideIds.map(sqlLiteral).join(',')});
    `
    : 'delete from site_login_slides;')

  const assets = Array.isArray(payload.assets) ? payload.assets : []
  for (const [index, asset] of assets.entries()) {
    const key = String(asset.key || asset.assetKey || '').trim()
    if (!key) continue
    const imageUrl = normalizeManagedAssetUrl(asset.imageUrl || '', '首页资产图片')
    await executeSql(`
      insert into site_home_assets (asset_key, label, title, description, image_url, sort_order, status)
      values (
        ${sqlLiteral(key)},
        ${sqlLiteral(asset.label || '')},
        ${sqlLiteral(asset.title || '')},
        ${sqlLiteral(asset.description || '')},
        ${sqlLiteral(imageUrl)},
        ${toSafeInteger(asset.sortOrder, (index + 1) * 10)},
        ${sqlLiteral(normalizeSiteStatus(asset.status))}
      )
      on conflict (asset_key) do update set
        label = excluded.label,
        title = excluded.title,
        description = excluded.description,
        image_url = excluded.image_url,
        sort_order = excluded.sort_order,
        status = excluded.status,
        updated_at = now();
    `)
    if (key === 'agent_workflow_home') {
      await executeSql(`
        update agent_workflow_cases
        set image_url = ${sqlLiteral(imageUrl)}
        where id = (
          select id from agent_workflow_cases
          order by sort_order
          limit 1
        );
      `)
    }
  }

  const featuredExpertInput = Array.isArray(payload.featuredExpertIds)
    ? payload.featuredExpertIds
    : Array.isArray(payload.featuredExperts)
      ? payload.featuredExperts.map((item) => item?.id || item?.expertId)
      : null

  if (featuredExpertInput) {
    const featuredExpertIds = [...new Set(featuredExpertInput.map(String).filter(isUuid))]
    if (featuredExpertIds.length > 4) {
      const error = new Error('首页精选专家最多选择 4 位')
      error.statusCode = 400
      error.code = 'invalid_home_featured_expert_count'
      throw error
    }

    await executeSql('delete from site_home_featured_experts;')
    for (const [index, expertId] of featuredExpertIds.entries()) {
      await executeSql(`
        insert into site_home_featured_experts (expert_id, sort_order)
        select id, ${(index + 1) * 10}
        from expert_profiles
        where id = ${sqlLiteral(expertId)}::uuid
          and deleted_at is null
        on conflict (expert_id) do update set
          sort_order = excluded.sort_order,
          updated_at = now();
      `)
    }
  }

  if (Array.isArray(payload.blocks)) {
    await saveSitePageBlocks('home', payload.blocks)
  }

  return getSiteHome({ includeHidden: true })
}

export async function getSiteAbout(options = {}) {
  const includeHidden = Boolean(options.includeHidden)
  return queryJson(`
    select json_build_object(
      'content', (
        select json_build_object(
          'heroTitle', hero_title,
          'heroDescription', hero_description,
          'heroExplanation', hero_explanation,
          'heroCoverUrl', hero_cover_url,
          'workTitle', work_title,
          'workDescription', work_description,
          'workImageUrl', work_image_url,
          'serviceTitle', service_title,
          'serviceDescription', service_description,
          'contactAddress', contact_address,
          'contactPhone', contact_phone,
          'contactEmail', contact_email,
          'contactHours', contact_hours,
          'updatedAt', updated_at
        )
        from site_about_content
        where id = 'about'
        limit 1
      ),
      'workSteps', ${siteAboutItemsSubquery('work_steps', includeHidden)},
      'serviceObjects', ${siteAboutItemsSubquery('service_objects', includeHidden)},
      'representativeWork', ${siteAboutItemsSubquery('representative_work', includeHidden)},
      'cooperationModes', ${siteAboutItemsSubquery('cooperation_modes', includeHidden)},
      'honors', ${siteAboutItemsSubquery('honors', includeHidden)},
      'partners', ${siteAboutItemsSubquery('partners', includeHidden)},
      'team', ${siteAboutItemsSubquery('team', includeHidden)}
    );
  `)
}

export async function updateAdminSiteAbout(token, payload = {}) {
  await requireAdmin(token, 'site:write')
  const content = payload.content || {}
  await executeSql(`
    insert into site_about_content (
      id, hero_title, hero_description, hero_explanation, hero_cover_url,
      work_title, work_description, work_image_url, service_title, service_description,
      contact_address, contact_phone, contact_email, contact_hours
    )
    values (
      'about',
      ${sqlLiteral(content.heroTitle || '')},
      ${sqlLiteral(content.heroDescription || '')},
      ${sqlLiteral(content.heroExplanation || '')},
      ${sqlLiteral(normalizeManagedAssetUrl(content.heroCoverUrl || '', '关于我们头图'))},
      ${sqlLiteral(content.workTitle || '')},
      ${sqlLiteral(content.workDescription || '')},
      ${sqlLiteral(normalizeManagedAssetUrl(content.workImageUrl || '', '关于我们工作图'))},
      ${sqlLiteral(content.serviceTitle || '')},
      ${sqlLiteral(content.serviceDescription || '')},
      ${sqlLiteral(content.contactAddress || '')},
      ${sqlLiteral(content.contactPhone || '')},
      ${sqlLiteral(content.contactEmail || '')},
      ${sqlLiteral(content.contactHours || '')}
    )
    on conflict (id) do update set
      hero_title = excluded.hero_title,
      hero_description = excluded.hero_description,
      hero_explanation = excluded.hero_explanation,
      hero_cover_url = excluded.hero_cover_url,
      work_title = excluded.work_title,
      work_description = excluded.work_description,
      work_image_url = excluded.work_image_url,
      service_title = excluded.service_title,
      service_description = excluded.service_description,
      contact_address = excluded.contact_address,
      contact_phone = excluded.contact_phone,
      contact_email = excluded.contact_email,
      contact_hours = excluded.contact_hours,
      updated_at = now();
  `)

  for (const section of aboutSections) {
    const items = Array.isArray(payload[section]) ? payload[section] : null
    if (!items) continue
    const ids = []
    for (const [index, item] of items.entries()) {
      const id = String(item.id || `${section}-${randomUUID()}`).trim()
      ids.push(id)
      const metadata = section === 'team'
        ? { ...(item.metadata || {}), role: String(item.role || item.metadata?.role || '') }
        : (item.metadata || {})
      await executeSql(`
        insert into site_about_items (
          id, section, title, description, image_url, sort_order, status, metadata
        )
        values (
          ${sqlLiteral(id)},
          ${sqlLiteral(section)},
          ${sqlLiteral(item.title || '')},
          ${sqlLiteral(item.description || '')},
          ${sqlLiteral(normalizeManagedAssetUrl(item.imageUrl || '', '关于我们条目图片'))},
          ${toSafeInteger(item.sortOrder, (index + 1) * 10)},
          ${sqlLiteral(normalizeSiteStatus(item.status))},
          ${sqlJsonb(metadata)}
        )
        on conflict (id) do update set
          section = excluded.section,
          title = excluded.title,
          description = excluded.description,
          image_url = excluded.image_url,
          sort_order = excluded.sort_order,
          status = excluded.status,
          metadata = excluded.metadata,
          updated_at = now();
      `)
    }
    if (ids.length) {
      await executeSql(`
        delete from site_about_items
        where section = ${sqlLiteral(section)}
          and id not in (${ids.map(sqlLiteral).join(',')});
      `)
    } else {
      await executeSql(`delete from site_about_items where section = ${sqlLiteral(section)};`)
    }
  }

  return getSiteAbout({ includeHidden: true })
}

export async function getSiteConsulting(options = {}) {
  const includeHidden = Boolean(options.includeHidden)
  return queryJson(`
    select json_build_object(
      'content', (
        select json_build_object(
          'heroTitle', hero_title,
          'heroDescription', hero_description,
          'heroExplanation', hero_explanation,
          'heroImageUrl', hero_image_url,
          'directionTitle', direction_title,
          'directionDescription', direction_description,
          'casesTitle', cases_title,
          'casesDescription', cases_description,
          'stepsTitle', steps_title,
          'stepsDescription', steps_description,
          'updatedAt', updated_at
        )
        from site_consulting_content
        where id = 'consulting'
        limit 1
      ),
      'directions', coalesce((
        select json_agg(${siteConsultingDirectionJson('d')} order by d.sort_order, d.created_at)
        from site_consulting_directions d
        where ${includeHidden ? 'true' : "d.status = 'published'"}
      ), '[]'::json),
      'cases', coalesce((
        select json_agg(${siteConsultingCaseJson('c')} order by c.sort_order, c.created_at)
        from site_consulting_cases c
        where ${includeHidden ? 'true' : "c.status = 'published'"}
      ), '[]'::json),
      'steps', coalesce((
        select json_agg(${siteConsultingStepJson('s')} order by s.sort_order, s.created_at)
        from site_consulting_steps s
        where ${includeHidden ? 'true' : "s.status = 'published'"}
      ), '[]'::json),
      'optionSets', ${consultingOptionSetsSubquery(includeHidden)}
    );
  `)
}

function normalizeConsultingSlug(value, fallback) {
  const slug = String(value || fallback || '').trim()
  if (!/^[a-z0-9][a-z0-9-]{2,96}$/.test(slug)) {
    const error = new Error('规划咨询方向 slug 只能包含小写字母、数字和短横线')
    error.statusCode = 400
    error.code = 'invalid_consulting_direction_slug'
    throw error
  }
  return slug
}

export async function updateAdminSiteConsulting(token, payload = {}) {
  await requireAdmin(token, 'site:write')
  const content = payload.content || {}
  await executeSql(`
    insert into site_consulting_content (
      id, hero_title, hero_description, hero_explanation, hero_image_url,
      direction_title, direction_description, cases_title, cases_description,
      steps_title, steps_description
    )
    values (
      'consulting',
      ${sqlLiteral(content.heroTitle || '')},
      ${sqlLiteral(content.heroDescription || '')},
      ${sqlLiteral(content.heroExplanation || '')},
      ${sqlLiteral(normalizeManagedAssetUrl(content.heroImageUrl || '', '规划咨询头图'))},
      ${sqlLiteral(content.directionTitle || '')},
      ${sqlLiteral(content.directionDescription || '')},
      ${sqlLiteral(content.casesTitle || '')},
      ${sqlLiteral(content.casesDescription || '')},
      ${sqlLiteral(content.stepsTitle || '')},
      ${sqlLiteral(content.stepsDescription || '')}
    )
    on conflict (id) do update set
      hero_title = excluded.hero_title,
      hero_description = excluded.hero_description,
      hero_explanation = excluded.hero_explanation,
      hero_image_url = excluded.hero_image_url,
      direction_title = excluded.direction_title,
      direction_description = excluded.direction_description,
      cases_title = excluded.cases_title,
      cases_description = excluded.cases_description,
      steps_title = excluded.steps_title,
      steps_description = excluded.steps_description,
      updated_at = now();
  `)

  const directions = Array.isArray(payload.directions) ? payload.directions : []
  const directionIds = []
  for (const [index, direction] of directions.entries()) {
    const id = String(direction.id || `consulting-direction-${randomUUID()}`).trim()
    directionIds.push(id)
    const title = String(direction.title || '').trim()
    const slug = normalizeConsultingSlug(direction.slug, slugify(title))
    await executeSql(`
      insert into site_consulting_directions (
        id, slug, title, summary, description, note, image_url, scenarios,
        owner_department, owner_org_unit_id, default_assignee_id,
        sort_order, status
      )
      values (
        ${sqlLiteral(id)},
        ${sqlLiteral(slug)},
        ${sqlLiteral(title)},
        ${sqlLiteral(direction.summary || '')},
        ${sqlLiteral(direction.description || '')},
        ${sqlLiteral(direction.note || '')},
        ${sqlLiteral(normalizeManagedAssetUrl(direction.imageUrl || '', '规划咨询方向图片'))},
        ${sqlTextArray(direction.scenarios)},
        ${sqlLiteral(direction.ownerDepartment ?? direction.owner_department ?? '')},
        ${isUuid(direction.ownerOrgUnitId || direction.owner_org_unit_id) ? `${sqlLiteral(direction.ownerOrgUnitId || direction.owner_org_unit_id)}::uuid` : 'null'},
        ${isUuid(direction.defaultAssigneeId || direction.default_assignee_id) ? `${sqlLiteral(direction.defaultAssigneeId || direction.default_assignee_id)}::uuid` : 'null'},
        ${toSafeInteger(direction.sortOrder, (index + 1) * 10)},
        ${sqlLiteral(normalizeSiteStatus(direction.status))}
      )
      on conflict (id) do update set
        slug = excluded.slug,
        title = excluded.title,
        summary = excluded.summary,
        description = excluded.description,
        note = excluded.note,
        image_url = excluded.image_url,
        scenarios = excluded.scenarios,
        owner_department = excluded.owner_department,
        owner_org_unit_id = excluded.owner_org_unit_id,
        default_assignee_id = excluded.default_assignee_id,
        sort_order = excluded.sort_order,
        status = excluded.status,
        updated_at = now();
    `)
  }
  await executeSql(directionIds.length ? `
    delete from site_consulting_directions
    where id not in (${directionIds.map(sqlLiteral).join(',')});
  ` : 'delete from site_consulting_directions;')

  const cases = Array.isArray(payload.cases) ? payload.cases : []
  const caseIds = []
  for (const [index, item] of cases.entries()) {
    const id = String(item.id || `consulting-case-${randomUUID()}`).trim()
    caseIds.push(id)
    await executeSql(`
      insert into site_consulting_cases (id, type, title, summary, image_url, sort_order, status)
      values (
        ${sqlLiteral(id)},
        ${sqlLiteral(item.type || '')},
        ${sqlLiteral(item.title || '')},
        ${sqlLiteral(item.summary || '')},
        ${sqlLiteral(normalizeManagedAssetUrl(item.imageUrl || '', '规划咨询案例图片'))},
        ${toSafeInteger(item.sortOrder, (index + 1) * 10)},
        ${sqlLiteral(normalizeSiteStatus(item.status))}
      )
      on conflict (id) do update set
        type = excluded.type,
        title = excluded.title,
        summary = excluded.summary,
        image_url = excluded.image_url,
        sort_order = excluded.sort_order,
        status = excluded.status,
        updated_at = now();
    `)
  }
  await executeSql(caseIds.length ? `
    delete from site_consulting_cases
    where id not in (${caseIds.map(sqlLiteral).join(',')});
  ` : 'delete from site_consulting_cases;')

  const steps = Array.isArray(payload.steps) ? payload.steps : []
  const stepIds = []
  for (const [index, step] of steps.entries()) {
    const id = String(step.id || `consulting-step-${randomUUID()}`).trim()
    stepIds.push(id)
    await executeSql(`
      insert into site_consulting_steps (id, title, description, sort_order, status)
      values (
        ${sqlLiteral(id)},
        ${sqlLiteral(step.title || '')},
        ${sqlLiteral(step.description || '')},
        ${toSafeInteger(step.sortOrder, (index + 1) * 10)},
        ${sqlLiteral(normalizeSiteStatus(step.status))}
      )
      on conflict (id) do update set
        title = excluded.title,
        description = excluded.description,
        sort_order = excluded.sort_order,
        status = excluded.status,
        updated_at = now();
    `)
  }
  await executeSql(stepIds.length ? `
    delete from site_consulting_steps
    where id not in (${stepIds.map(sqlLiteral).join(',')});
  ` : 'delete from site_consulting_steps;')

  if (Array.isArray(payload.optionSets)) {
    await saveConsultingOptionSets(payload.optionSets)
  }

  return getSiteConsulting({ includeHidden: true })
}

async function saveConsultingOptionSets(optionSets = []) {
  const setCodes = []
  for (const [setIndex, set] of optionSets.entries()) {
    const code = String(set.code || '').trim()
    const name = String(set.name || '').trim()
    if (!code || !name) continue
    setCodes.push(code)
    await executeSql(`
      insert into consulting_option_sets (code, name, description, sort_order, status)
      values (
        ${sqlLiteral(code)},
        ${sqlLiteral(name)},
        ${sqlLiteral(set.description || '')},
        ${toSafeInteger(set.sortOrder ?? set.sort_order, (setIndex + 1) * 10)},
        ${sqlLiteral(set.status === 'disabled' ? 'disabled' : 'active')}
      )
      on conflict (code) do update set
        name = excluded.name,
        description = excluded.description,
        sort_order = excluded.sort_order,
        status = excluded.status,
        updated_at = now();
    `)
    const options = Array.isArray(set.options) ? set.options : []
    const optionCodes = []
    for (const [optionIndex, option] of options.entries()) {
      const optionCode = String(option.code || option.value || option.label || '').trim()
      const label = String(option.label || option.name || '').trim()
      if (!optionCode || !label) continue
      optionCodes.push(optionCode)
      await executeSql(`
        insert into consulting_options (set_code, code, label, description, sort_order, status, metadata)
        values (
          ${sqlLiteral(code)},
          ${sqlLiteral(optionCode)},
          ${sqlLiteral(label)},
          ${sqlLiteral(option.description || '')},
          ${toSafeInteger(option.sortOrder ?? option.sort_order, (optionIndex + 1) * 10)},
          ${sqlLiteral(option.status === 'disabled' ? 'disabled' : 'active')},
          ${sqlJsonb(option.metadata || {})}
        )
        on conflict (set_code, code) do update set
          label = excluded.label,
          description = excluded.description,
          sort_order = excluded.sort_order,
          status = excluded.status,
          metadata = excluded.metadata,
          updated_at = now();
      `)
    }
    await executeSql(optionCodes.length ? `
      delete from consulting_options
      where set_code = ${sqlLiteral(code)}
        and code not in (${optionCodes.map(sqlLiteral).join(',')});
    ` : `
      delete from consulting_options
      where set_code = ${sqlLiteral(code)};
    `)
  }
}

export async function listAdminConsultingOptions(token) {
  await requireAdmin(token, 'consulting_option:read')
  return queryJson(`select ${consultingOptionSetsSubquery(true)};`)
}

export async function updateAdminConsultingOptions(token, payload = {}) {
  const { actor } = await requireAdmin(token, 'consulting_option:write')
  const optionSets = Array.isArray(payload.optionSets) ? payload.optionSets : []
  await saveConsultingOptionSets(optionSets)
  const updated = await listAdminConsultingOptions(token)
  await auditAdmin(actor, 'consulting_options.update', 'consulting_options', 'all', updated)
  return { optionSets: updated }
}

const dataServiceOptionSetCodes = [
  'data_service_organization_type',
  'data_service_service_type',
  'data_service_industry_scene',
  'data_service_delivery_format',
  'data_service_data_volume',
  'data_service_budget_range',
  'data_service_contact_time',
]

function dataServiceDefaultOptionSets() {
  const makeOptions = (setKey, labels) => labels.map((label, index) => ({
    code: `${setKey}_${index + 1}`,
    label,
    description: '',
    sortOrder: (index + 1) * 10,
    status: 'active',
    metadata: {},
  }))
  return [
    {
      code: 'data_service_organization_type',
      name: '组织类型',
      description: '数据服务申请表的组织类型选项。',
      sortOrder: 10,
      status: 'active',
      options: makeOptions('organization_type', ['政府机构', '国有企业', '民营企业', '事业单位', '产业园区', '研究机构', '其他组织']),
    },
    {
      code: 'data_service_service_type',
      name: '服务类型',
      description: '数据服务申请表的服务类型选项。',
      sortOrder: 20,
      status: 'active',
      options: makeOptions('service_type', ['数据服务定制', '数据表整理', '政策库整理', '企业名录梳理', '研究资料整理', '指标口径梳理']),
    },
    {
      code: 'data_service_industry_scene',
      name: '行业场景',
      description: '数据服务申请表的行业和应用场景选项。',
      sortOrder: 30,
      status: 'active',
      options: makeOptions('industry_scene', ['政务治理', '产业研究', '企业经营', '园区招商', '数据资产', '课题研究']),
    },
    {
      code: 'data_service_delivery_format',
      name: '交付方式',
      description: '数据服务期望交付形式。',
      sortOrder: 40,
      status: 'active',
      options: makeOptions('delivery_format', ['Excel 数据表', 'CSV 数据文件', 'PDF 分析报告', 'ZIP 资料包', '可视化看板', '按沟通结果确定']),
    },
    {
      code: 'data_service_data_volume',
      name: '数据量级',
      description: '数据服务申请表的数据范围与量级选项。',
      sortOrder: 50,
      status: 'active',
      options: makeOptions('data_volume', ['少量样本', '单主题数据包', '多地区/多年份', '持续更新库', '暂不确定']),
    },
    {
      code: 'data_service_budget_range',
      name: '预算范围',
      description: '数据服务申请表的预算范围选项。',
      sortOrder: 60,
      status: 'active',
      options: makeOptions('budget_range', ['暂不确定', '1 万以下', '1-5 万', '5-20 万', '20 万以上']),
    },
    {
      code: 'data_service_contact_time',
      name: '方便联系时间',
      description: '数据服务申请表的联系时间选项。',
      sortOrder: 70,
      status: 'active',
      options: makeOptions('contact_time', ['工作日 9:00-17:30', '工作日上午', '工作日下午', '下班后沟通', '电话前请短信确认']),
    },
  ]
}

function normalizeDataServiceOptionSets(optionSets = []) {
  return optionSets.map((set) => {
    const shortSetCode = String(set.code || '').replace(/^data_service_/, '') || 'option'
    const seen = new Set()
    const options = (Array.isArray(set.options) ? set.options : []).map((option, index) => {
      const rawCode = String(option.code || '').trim()
      let code = rawCode
      if (!code || code.startsWith('news-') || seen.has(code)) {
        code = `${shortSetCode}_${index + 1}`
      }
      seen.add(code)
      return { ...option, code }
    })
    return { ...set, options }
  })
}

async function listDataServiceOptionSets(includeHidden = false) {
  const optionSets = await queryJson(`
    select coalesce((
      select json_agg(${consultingOptionSetJson('cos', includeHidden)} order by cos.sort_order, cos.created_at)
      from consulting_option_sets cos
      where cos.code in (${dataServiceOptionSetCodes.map(sqlLiteral).join(',')})
        and (${includeHidden ? 'true' : "cos.status = 'active'"})
    ), '[]'::json);
  `)
  const source = Array.isArray(optionSets) && optionSets.length ? optionSets : dataServiceDefaultOptionSets()
  return normalizeDataServiceOptionSets(source)
}

export async function listDataServiceOptions() {
  return listDataServiceOptionSets(false)
}

export async function listAdminDataServiceOptions(token) {
  await requireAdminAny(token, ['data_service_option:read', 'data_page:read'])
  return listDataServiceOptionSets(true)
}

export async function updateAdminDataServiceOptions(token, payload = {}) {
  const { actor } = await requireAdminAny(token, ['data_service_option:write', 'data_page:write'])
  const inputSets = Array.isArray(payload.optionSets) ? payload.optionSets : []
  const normalized = inputSets.map((set, index) => {
    const rawCode = String(set.code || '').trim()
    const code = rawCode.startsWith('data_service_') ? rawCode : `data_service_${rawCode}`
    return {
      ...set,
      code,
      sortOrder: set.sortOrder ?? set.sort_order ?? (index + 1) * 10,
    }
  }).filter((set) => dataServiceOptionSetCodes.includes(set.code))
  await saveConsultingOptionSets(normalized.length ? normalized : dataServiceDefaultOptionSets())
  const updated = await listAdminDataServiceOptions(token)
  await auditAdmin(actor, 'data_service_options.update', 'data_service_options', 'all', updated)
  return { optionSets: updated }
}

function cooperationRequestJson(requestAlias = 'cr', userAlias = 'u') {
  return `json_build_object(
    'id', ${requestAlias}.id,
    'requestNo', ${requestAlias}.request_no,
    'userId', ${requestAlias}.user_id,
    'name', ${requestAlias}.name,
    'phone', ${requestAlias}.phone,
    'organization', ${requestAlias}.organization,
    'demand', ${requestAlias}.demand,
    'status', ${requestAlias}.status,
    'assignedAccountId', ${requestAlias}.assigned_account_id,
    'processNote', ${requestAlias}.process_note,
    'convertedType', ${requestAlias}.converted_type,
    'convertedRefId', ${requestAlias}.converted_ref_id,
    'convertedAt', ${requestAlias}.converted_at,
    'completedAt', ${requestAlias}.completed_at,
    'createdAt', ${requestAlias}.created_at,
    'updatedAt', ${requestAlias}.updated_at,
    'user', ${userJson(userAlias)}
  )`
}

export async function createCooperationRequest(token, payload = {}) {
  const { user } = await requireUser(token)
  const name = String(payload.name || '').trim()
  const phone = String(payload.phone || user.phone || '').trim()
  const organization = String(payload.organization || '').trim()
  const demand = String(payload.demand || '').trim()
  if (!name || !phone || !organization || !demand) {
    const error = new Error('请完整填写姓名、联系方式、所在机构和合作需求')
    error.statusCode = 400
    error.code = 'invalid_cooperation_request'
    throw error
  }
  const requestNo = await nextBusinessNo('COO')
  return queryJson(`
    insert into cooperation_requests (request_no, user_id, name, phone, organization, demand)
    values (
      ${sqlLiteral(requestNo)},
      ${sqlLiteral(user.id)}::uuid,
      ${sqlLiteral(name)},
      ${sqlLiteral(phone)},
      ${sqlLiteral(organization)},
      ${sqlLiteral(demand)}
    )
    returning json_build_object(
      'id', id,
      'requestNo', request_no,
      'userId', user_id,
      'name', name,
      'phone', phone,
      'organization', organization,
      'demand', demand,
      'status', status,
      'completedAt', completed_at,
      'createdAt', created_at,
      'updatedAt', updated_at,
      'user', ${sqlLiteral(JSON.stringify(publicUser(user)))}::json
    );
  `)
}

export async function listAdminCooperationRequests(token, filters = {}) {
  await requireAdmin(token, 'cooperation:read')
  return queryJson(`
    select coalesce(json_agg(${cooperationRequestJson('cr', 'u')} order by cr.created_at desc), '[]'::json)
    from cooperation_requests cr
    join users u on u.id = cr.user_id
    where (${sqlLiteral(filters.status || '')} = '' or cr.status = ${sqlLiteral(filters.status || '')});
  `)
}

export async function getAdminCooperationRequest(token, requestId) {
  await requireAdmin(token, 'cooperation:read')
  const where = isUuid(requestId)
    ? `cr.id = ${sqlLiteral(requestId)}::uuid`
    : `(cr.request_no = ${sqlLiteral(requestId)} or cr.legacy_request_no = ${sqlLiteral(requestId)})`
  return queryJson(`
    select ${cooperationRequestJson('cr', 'u')}
    from cooperation_requests cr
    join users u on u.id = cr.user_id
    where ${where}
    limit 1;
  `)
}

export async function completeAdminCooperationRequest(token, requestId) {
  await requireAdmin(token, 'cooperation:update')
  const where = isUuid(requestId)
    ? `id = ${sqlLiteral(requestId)}::uuid`
    : `(request_no = ${sqlLiteral(requestId)} or legacy_request_no = ${sqlLiteral(requestId)})`
  const updated = await queryJson(`
    update cooperation_requests
    set status = 'completed',
        completed_at = coalesce(completed_at, now()),
        updated_at = now()
    where ${where}
    returning json_build_object('requestNo', request_no);
  `)
  if (!updated) {
    const error = new Error('合作咨询不存在')
    error.statusCode = 404
    error.code = 'cooperation_request_not_found'
    throw error
  }
  return getAdminCooperationRequest(token, updated.requestNo || requestId)
}

export async function updateAdminCooperationRequest(token, requestId, payload = {}) {
  const { actor } = await requireAdmin(token, 'cooperation:update')
  const where = isUuid(requestId)
    ? `id = ${sqlLiteral(requestId)}::uuid`
    : `(request_no = ${sqlLiteral(requestId)} or legacy_request_no = ${sqlLiteral(requestId)})`
  const status = payload.status === 'completed' ? 'completed' : payload.status === 'pending' ? 'pending' : null
  const assigneeId = isUuid(payload.assignedAccountId || payload.assigned_account_id)
    ? String(payload.assignedAccountId || payload.assigned_account_id)
    : null
  const updated = await queryJson(`
    update cooperation_requests
    set status = coalesce(${sqlLiteral(status)}, status),
        assigned_account_id = coalesce(${assigneeId ? `${sqlLiteral(assigneeId)}::uuid` : 'null'}, assigned_account_id),
        process_note = coalesce(${sqlLiteral(payload.processNote ?? payload.process_note)}, process_note),
        completed_at = case
          when ${sqlLiteral(status)} = 'completed' and completed_at is null then now()
          when ${sqlLiteral(status)} = 'pending' then null
          else completed_at
        end,
        updated_at = now()
    where ${where}
    returning request_no;
  `)
  if (!updated) {
    const error = new Error('合作咨询不存在')
    error.statusCode = 404
    error.code = 'cooperation_request_not_found'
    throw error
  }
  const item = await getAdminCooperationRequest(token, updated.requestNo || requestId)
  await auditAdmin(actor, 'cooperation.update', 'cooperation_request', item.id, item)
  return item
}

export async function convertAdminCooperationRequest(token, requestId, payload = {}) {
  const { actor } = await requireAdmin(token, 'cooperation:update')
  const where = isUuid(requestId)
    ? `cr.id = ${sqlLiteral(requestId)}::uuid`
    : `(cr.request_no = ${sqlLiteral(requestId)} or cr.legacy_request_no = ${sqlLiteral(requestId)})`
  const request = await queryJson(`
    select ${cooperationRequestJson('cr', 'u')}
    from cooperation_requests cr
    join users u on u.id = cr.user_id
    where ${where}
    limit 1;
  `)
  if (!request) {
    const error = new Error('合作咨询不存在')
    error.statusCode = 404
    error.code = 'cooperation_request_not_found'
    throw error
  }
  if (request.convertedRefId) {
    const error = new Error('合作咨询已经转化过')
    error.statusCode = 409
    error.code = 'cooperation_already_converted'
    throw error
  }
  const targetType = String(payload.targetType || payload.convertedType || payload.type || 'consulting').trim()
  let target
  if (targetType === 'data_service') {
    const requestNo = await nextBusinessNo('DSR')
    target = await queryJson(`
      insert into data_service_requests (
        request_no, user_id, status, current_step, company, organization_type,
        contact_name, contact_phone, email, city, data_topic, service_type,
        region_scope, time_range, industry_scene, expected_format, data_volume,
        usage_purpose, requirement, expected_result, deadline, budget_range,
        preferred_contact_time, extra_note, assigned_account_id, contact_note
      )
      values (
        ${sqlLiteral(requestNo)},
        ${sqlLiteral(request.userId)}::uuid,
        'pending_contact',
        1,
        ${sqlLiteral(request.organization || '')},
        ${sqlLiteral(payload.organizationType || '')},
        ${sqlLiteral(request.name || '')},
        ${sqlLiteral(request.phone || '')},
        ${sqlLiteral(payload.email || '')},
        ${sqlLiteral(payload.city || '')},
        ${sqlLiteral(payload.dataTopic || '合作咨询转入')},
        ${sqlLiteral(payload.serviceType || 'custom')},
        ${sqlLiteral(payload.regionScope || '')},
        ${sqlLiteral(payload.timeRange || '')},
        ${sqlLiteral(payload.industryScene || '')},
        ${sqlLiteral(payload.expectedFormat || '')},
        ${sqlLiteral(payload.dataVolume || '')},
        ${sqlLiteral(payload.usagePurpose || '')},
        ${sqlLiteral(payload.requirement || request.demand || '')},
        ${sqlLiteral(payload.expectedResult || '')},
        ${sqlLiteral(payload.deadline || '')},
        ${sqlLiteral(payload.budgetRange || '')},
        ${sqlLiteral(payload.preferredContactTime || '')},
        ${sqlLiteral(`由合作咨询 ${request.requestNo || request.id} 转入。${payload.note || ''}`.trim())},
        ${isUuid(payload.assignedAccountId || payload.assigned_account_id || request.assignedAccountId) ? `${sqlLiteral(payload.assignedAccountId || payload.assigned_account_id || request.assignedAccountId)}::uuid` : 'null'},
        ${sqlLiteral(payload.contactNote || payload.processNote || '')}
      )
      returning json_build_object('id', id, 'requestNo', request_no, 'type', 'data_service');
    `)
  } else if (targetType === 'consulting') {
    const requestNo = await nextBusinessNo('CON')
    target = await queryJson(`
      insert into consulting_requests (
        request_no, user_id, status, current_step, company, contact_name,
        contact_phone, email, direction, organization_type, industry, city,
        project_stage, urgency, budget_range, expected_start, current_situation,
        requirement, goals, preferred_contact_time, owner_department, assigned_account_id,
        progress_note
      )
      values (
        ${sqlLiteral(requestNo)},
        ${sqlLiteral(request.userId)}::uuid,
        'pending',
        0,
        ${sqlLiteral(request.organization || '')},
        ${sqlLiteral(request.name || '')},
        ${sqlLiteral(request.phone || '')},
        ${sqlLiteral(payload.email || '')},
        ${sqlLiteral(payload.direction || '合作咨询转入')},
        ${sqlLiteral(payload.organizationType || '')},
        ${sqlLiteral(payload.industry || '')},
        ${sqlLiteral(payload.city || '')},
        ${sqlLiteral(payload.projectStage || '')},
        ${sqlLiteral(payload.urgency || '')},
        ${sqlLiteral(payload.budgetRange || '')},
        ${sqlLiteral(payload.expectedStart || '')},
        ${sqlLiteral(payload.currentSituation || request.demand || '')},
        ${sqlLiteral(payload.requirement || request.demand || '')},
        ${sqlLiteral(payload.goals || '')},
        ${sqlLiteral(payload.preferredContactTime || '')},
        ${sqlLiteral(payload.ownerDepartment || consultingOwner(payload.direction || ''))},
        ${isUuid(payload.assignedAccountId || payload.assigned_account_id || request.assignedAccountId) ? `${sqlLiteral(payload.assignedAccountId || payload.assigned_account_id || request.assignedAccountId)}::uuid` : 'null'},
        ${sqlLiteral(`由合作咨询 ${request.requestNo || request.id} 转入。${payload.note || ''}`.trim())}
      )
      returning json_build_object('id', id, 'requestNo', request_no, 'type', 'consulting');
    `)
  } else {
    const error = new Error('合作咨询转化目标无效')
    error.statusCode = 400
    error.code = 'invalid_cooperation_convert_type'
    throw error
  }

  await executeSql(`
    update cooperation_requests
    set status = 'completed',
        process_note = coalesce(nullif(${sqlLiteral(payload.processNote || payload.note || '')}, ''), process_note),
        converted_type = ${sqlLiteral(target.type)},
        converted_ref_id = ${sqlLiteral(target.id)}::uuid,
        converted_at = now(),
        completed_at = coalesce(completed_at, now()),
        updated_at = now()
    where id = ${sqlLiteral(request.id)}::uuid;
  `)
  const updated = await getAdminCooperationRequest(token, request.id)
  await auditAdmin(actor, 'cooperation.convert', 'cooperation_request', request.id, { cooperation: updated, target })
  return { cooperation: updated, target }
}

export async function getMembershipCatalog() {
  const catalog = await listMembershipCatalogRecord()
  const benefits = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'code', code,
      'label', label,
      'groupKey', group_key,
      'benefitType', benefit_type,
      'targetType', target_type,
      'targetRef', target_ref,
      'executionMode', execution_mode,
      'defaultValue', default_value,
      'status', status,
      'sortOrder', sort_order,
      'description', description
    ) order by sort_order, code), '[]'::json)
    from membership_benefit_rules
    where status = 'active';
  `)
  const planBenefits = await queryJson(`
    select coalesce(json_object_agg(plan_product_id, items), '{}'::json)
    from (
      select
        pb.plan_product_id,
        json_agg(json_build_object(
          'benefitCode', pb.benefit_code,
          'enabled', pb.enabled,
          'value', pb.value_json,
          'displayLabel', pb.display_label,
          'sortOrder', pb.sort_order,
          'status', pb.status
        ) order by pb.sort_order) as items
      from membership_plan_benefits pb
      join membership_benefit_rules r on r.code = pb.benefit_code
      where pb.status = 'active'
        and r.status = 'active'
      group by pb.plan_product_id
    ) t;
  `)
  return {
    ...catalog,
    benefits: benefits || [],
    planBenefits: planBenefits || {},
  }
}

export async function listAgentCatalog() {
  return listAgentCatalogRecord()
}

export async function getAgentProduct(slug) {
  return findAgentProductRecord(slug)
}

export async function listDataCatalog() {
  return listDataCatalogRecord()
}

export async function getDataProduct(slug) {
  return findDataProductRecord(slug)
}

export async function listEventCatalog() {
  return listEventCatalogRecord()
}

export async function getEventProduct(slug, options = {}) {
  return findEventProductRecord(slug, options)
}

export async function listCourseCatalog() {
  return listCourseCatalogRecord()
}

export async function getCourseProduct(slug) {
  return findCourseProductRecord(slug)
}

export async function listNewsArticles(filters = {}) {
  const category = String(filters.category || filters.categoryCode || filters.category_code || '').trim()
  return queryJson(`
    select coalesce(json_agg(${newsArticleJson('n')} order by n.published_at desc nulls last, n.created_at desc), '[]'::json)
    from news_articles n
    where n.status = 'published'
      and (${sqlLiteral(category)} = '' or n.category = ${sqlLiteral(category)} or n.category_code = ${sqlLiteral(category)});
  `)
}

function newsCategoryJson(alias = 'nc', includeAllArticles = false) {
  const articleStatusFilter = includeAllArticles ? 'true' : "n.status = 'published'"
  return `json_build_object(
    'code', ${alias}.code,
    'name', ${alias}.name,
    'description', ${alias}.description,
    'sortOrder', ${alias}.sort_order,
    'status', ${alias}.status,
    'articleCount', coalesce((
      select count(*)::int
      from news_articles n
      where ${articleStatusFilter}
        and (n.category_code = ${alias}.code or n.category = ${alias}.name)
    ), 0),
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

export async function listNewsCategories() {
  return queryJson(`
    select coalesce(json_agg(${newsCategoryJson('nc')} order by nc.sort_order, nc.created_at), '[]'::json)
    from news_categories nc
    where nc.status = 'active';
  `)
}

export async function listAdminNewsCategories(token) {
  await requireAdmin(token, 'news_category:read')
  return queryJson(`
    select coalesce(json_agg(${newsCategoryJson('nc', true)} order by nc.sort_order, nc.created_at), '[]'::json)
    from news_categories nc;
  `)
}

function normalizeNewsCategoryPayload(payload = {}, creating = false) {
  const name = String(payload.name || '').trim()
  const code = String(payload.code || slugify(name) || `category-${randomUUID().slice(0, 8)}`).trim()
  const status = payload.status === 'disabled' ? 'disabled' : 'active'
  if (!name) {
    const error = new Error('资讯分类名称不能为空')
    error.statusCode = 400
    error.code = 'invalid_news_category_name'
    throw error
  }
  if (!/^[a-z0-9][a-z0-9_-]{1,62}$/.test(code)) {
    const error = new Error('资讯分类码只能包含小写字母、数字、下划线和短横线')
    error.statusCode = 400
    error.code = 'invalid_news_category_code'
    throw error
  }
  return {
    code,
    name,
    description: String(payload.description || '').trim(),
    sortOrder: toSafeInteger(payload.sortOrder ?? payload.sort_order, creating ? 100 : 0),
    status,
  }
}

async function resolveNewsCategory(value) {
  const input = String(value || 'institute_news').trim() || 'institute_news'
  const category = await queryJson(`
    select ${newsCategoryJson('nc', true)}
    from news_categories nc
    where nc.status = 'active'
      and (nc.code = ${sqlLiteral(input)} or nc.name = ${sqlLiteral(input)})
    limit 1;
  `)
  if (!category) {
    const error = new Error('资讯分类无效或已禁用')
    error.statusCode = 400
    error.code = 'invalid_news_category'
    throw error
  }
  return category
}

export async function createAdminNewsCategory(token, payload = {}) {
  const { actor } = await requireAdmin(token, 'news_category:write')
  const item = normalizeNewsCategoryPayload(payload, true)
  const created = await queryJson(`
    insert into news_categories (code, name, description, sort_order, status)
    values (
      ${sqlLiteral(item.code)},
      ${sqlLiteral(item.name)},
      ${sqlLiteral(item.description)},
      ${item.sortOrder},
      ${sqlLiteral(item.status)}
    )
    on conflict (code) do update set
      name = excluded.name,
      description = excluded.description,
      sort_order = excluded.sort_order,
      status = excluded.status,
      updated_at = now()
    returning ${newsCategoryJson('news_categories', true)};
  `)
  await auditAdmin(actor, 'news_category.upsert', 'news_category', created.code, created)
  return created
}

export async function updateAdminNewsCategory(token, code, payload = {}) {
  const { actor } = await requireAdmin(token, 'news_category:write')
  const item = normalizeNewsCategoryPayload({ ...payload, code }, false)
  const updated = await queryJson(`
    update news_categories
    set name = ${sqlLiteral(item.name)},
        description = ${sqlLiteral(item.description)},
        sort_order = ${item.sortOrder},
        status = ${sqlLiteral(item.status)},
        updated_at = now()
    where code = ${sqlLiteral(code)}
    returning ${newsCategoryJson('news_categories', true)};
  `)
  if (!updated) {
    const error = new Error('资讯分类不存在')
    error.statusCode = 404
    error.code = 'news_category_not_found'
    throw error
  }
  await auditAdmin(actor, 'news_category.update', 'news_category', updated.code, updated)
  return updated
}

export async function deleteAdminNewsCategory(token, code) {
  const { actor } = await requireAdmin(token, 'news_category:write')
  const updated = await queryJson(`
    update news_categories
    set status = 'disabled', updated_at = now()
    where code = ${sqlLiteral(code)}
    returning ${newsCategoryJson('news_categories', true)};
  `)
  if (!updated) {
    const error = new Error('资讯分类不存在')
    error.statusCode = 404
    error.code = 'news_category_not_found'
    throw error
  }
  await auditAdmin(actor, 'news_category.disable', 'news_category', updated.code, updated)
  return { ok: true, item: updated }
}

export async function listFeaturedNewsArticles() {
  const snapshot = await queryJson(`
    select coalesce(json_agg(${newsArticleJson('n')} order by snapshot_item.ordinality), '[]'::json)
    from news_featured_snapshots s
    cross join unnest(s.article_ids) with ordinality as snapshot_item(article_id, ordinality)
    join news_articles n on n.id::text = snapshot_item.article_id
    where s.id = 'home'
      and n.status = 'published';
  `)
  if (snapshot?.length === 3) return snapshot
  return queryJson(`
    select coalesce(json_agg(${newsArticleJson('n')} order by n.featured_position), '[]'::json)
    from news_articles n
    where n.status = 'published'
      and n.featured_position between 1 and 3
    having count(*) = 3;
  `)
}

export async function getNewsArticle(slug) {
  return queryJson(`
    select ${newsArticleJson('n')}
    from news_articles n
    where n.status = 'published'
      and (n.slug = ${sqlLiteral(slug)} or n.id::text = ${sqlLiteral(slug)})
    limit 1;
  `)
}

function expertApplicationJson(alias = 'ea', userAlias = 'u') {
  return `json_build_object(
    'id', ${alias}.id,
    'applicationNo', ${alias}.application_no,
    'userId', ${alias}.user_id,
    'passportNo', ${alias}.passport_no,
    'name', ${alias}.name,
    'contactPhone', ${alias}.contact_phone,
    'organization', ${alias}.organization,
    'title', ${alias}.title,
    'directions', coalesce(to_json(${alias}.directions), '[]'::json),
    'photoFileId', ${alias}.photo_file_id,
    'photoUrl', ${alias}.photo_url,
    'projectExperience', ${alias}.project_experience,
    'representativeAchievements', ${alias}.representative_achievements,
    'homepageIntro', ${alias}.homepage_intro,
    'attachmentFileIds', coalesce(to_json(${alias}.attachment_file_ids), '[]'::json),
    'attachments', coalesce((
      select json_agg(json_build_object(
        'id', f.id,
        'fileName', f.file_name,
        'mimeType', f.mime_type,
        'fileSize', f.file_size,
        'url', '/api/files/' || f.id
      ) order by f.created_at)
      from uploaded_files f
      where f.id = any(${alias}.attachment_file_ids)
    ), '[]'::json),
    'status', ${alias}.status,
    'rejectReason', ${alias}.reject_reason,
    'reviewPriceAmount', ${alias}.review_price_amount,
    'reviewShareRatio', ${alias}.review_share_ratio,
    'reviewPublish', ${alias}.review_publish,
    'expertProfileId', ${alias}.expert_profile_id,
    'reviewedBy', ${alias}.reviewed_by,
    'reviewedAt', ${alias}.reviewed_at,
    'user', json_build_object(
      'id', ${userAlias}.id,
      'displayName', ${userAlias}.display_name,
      'phone', ${userAlias}.phone,
      'passportNo', ${userAlias}.passport_no,
      'organization', ${userAlias}.organization,
      'membershipLevel', ${userAlias}.membership_level
    ),
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function expertSlotJson(alias = 's') {
  return `json_build_object(
    'id', ${alias}.id,
    'expertId', ${alias}.expert_id,
    'startsAt', ${alias}.starts_at,
    'endsAt', ${alias}.ends_at,
    'status', ${alias}.status,
    'note', ${alias}.note,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function expertBookingJson(alias = 'b', expertAlias = 'e', userAlias = 'u') {
  return `json_build_object(
    'id', ${alias}.id,
    'bookingNo', ${alias}.booking_no,
    'expertId', ${alias}.expert_id,
    'userId', ${alias}.user_id,
    'passportNo', ${alias}.passport_no,
    'orderId', ${alias}.order_id,
    'slotId', ${alias}.slot_id,
    'startsAt', ${alias}.starts_at,
    'endsAt', ${alias}.ends_at,
    'contactName', ${alias}.contact_name,
    'contactPhone', ${alias}.contact_phone,
    'background', ${alias}.background,
    'question', ${alias}.question,
    'materialFileIds', coalesce(to_json(${alias}.material_file_ids), '[]'::json),
    'materialFiles', coalesce((
      select json_agg(json_build_object(
        'id', f.id,
        'fileName', f.file_name,
        'mimeType', f.mime_type,
        'fileSize', f.file_size,
        'url', '/api/files/' || f.id
      ) order by f.created_at)
      from uploaded_files f
      where f.id = any(${alias}.material_file_ids)
    ), '[]'::json),
    'status', ${alias}.status,
    'statusReason', ${alias}.status_reason,
    'holdExpiresAt', ${alias}.hold_expires_at,
    'cancelledAt', ${alias}.cancelled_at,
    'cancelledBy', ${alias}.cancelled_by,
    'completionSubmittedAt', ${alias}.completion_submitted_at,
    'userConfirmedAt', ${alias}.user_confirmed_at,
    'disputedAt', ${alias}.disputed_at,
    'disputeReason', ${alias}.dispute_reason,
    'meetingUrl', ${alias}.meeting_url,
    'meetingMethod', ${alias}.meeting_method,
    'meetingPlatform', ${alias}.meeting_platform,
    'meetingNo', ${alias}.meeting_no,
    'meetingPassword', ${alias}.meeting_password,
    'meetingNote', ${alias}.meeting_note,
    'meetingInternalNote', ${alias}.meeting_internal_note,
    'meetingUpdatedAt', ${alias}.meeting_updated_at,
    'pendingChange', (
      select json_build_object(
        'id', ebc.id,
        'requesterType', ebc.requester_type,
        'changeType', ebc.change_type,
        'requestedStartsAt', ebc.requested_starts_at,
        'requestedEndsAt', ebc.requested_ends_at,
        'reason', ebc.reason,
        'status', ebc.status,
        'userConfirmedAt', ebc.user_confirmed_at,
        'expertConfirmedAt', ebc.expert_confirmed_at,
        'adminNote', ebc.admin_note,
        'createdAt', ebc.created_at
      )
      from expert_booking_changes ebc
      where ebc.booking_id = ${alias}.id
        and ebc.status in ('requested', 'proposed')
      order by ebc.created_at desc
      limit 1
    ),
    'completedAt', ${alias}.completed_at,
    'expert', ${expertProfileJson(expertAlias)},
    'user', json_build_object(
      'id', ${userAlias}.id,
      'displayName', ${userAlias}.display_name,
      'phone', ${userAlias}.phone,
      'passportNo', ${userAlias}.passport_no,
      'organization', ${userAlias}.organization
    ),
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function expertWithdrawalJson(alias = 'w', expertAlias = 'e', userAlias = 'u') {
  return `json_build_object(
    'id', ${alias}.id,
    'withdrawalNo', ${alias}.withdrawal_no,
    'expertId', ${alias}.expert_id,
    'userId', ${alias}.user_id,
    'passportNo', ${alias}.passport_no,
    'amount', ${alias}.amount,
    'payeeName', ${alias}.payee_name,
    'bankAccount', ${alias}.bank_account,
    'bankName', ${alias}.bank_name,
    'status', ${alias}.status,
    'adminNote', ${alias}.admin_note,
    'handledBy', ${alias}.handled_by,
    'handledAt', ${alias}.handled_at,
    'paidAt', ${alias}.paid_at,
    'paidBank', ${alias}.paid_bank,
    'paymentSerialNo', ${alias}.payment_serial_no,
    'paymentProofFileId', ${alias}.payment_proof_file_id,
    'payoutNote', ${alias}.payout_note,
    'items', coalesce((
      select json_agg(json_build_object(
        'id', ewi.id,
        'settlementId', sr.id,
        'settlementNo', sr.settlement_no,
        'orderId', sr.order_id,
        'orderNo', o.order_no,
        'productTitle', p.title,
        'amount', ewi.amount,
        'settlementStatus', sr.status,
        'createdAt', ewi.created_at
      ) order by ewi.created_at)
      from expert_withdrawal_items ewi
      join settlement_records sr on sr.id = ewi.settlement_record_id
      join orders o on o.id = sr.order_id
      join products p on p.id = sr.product_id
      where ewi.withdrawal_id = ${alias}.id
    ), '[]'::json),
    'expert', ${expertProfileJson(expertAlias)},
    'user', json_build_object(
      'id', ${userAlias}.id,
      'displayName', ${userAlias}.display_name,
      'phone', ${userAlias}.phone,
      'passportNo', ${userAlias}.passport_no
    ),
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function agreementJson(alias = 'a') {
  return `json_build_object(
    'id', ${alias}.id,
    'title', ${alias}.title,
    'summary', ${alias}.summary,
    'content', ${alias}.content,
    'status', ${alias}.status,
    'updatedBy', ${alias}.updated_by,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

export async function listExpertDirections(filters = {}) {
  const includeDisabled = Boolean(filters.includeDisabled)
  return queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', id,
      'groupName', group_name,
      'directionType', direction_type,
      'name', name,
      'description', description,
      'sortOrder', sort_order,
      'status', status,
      'createdAt', created_at,
      'updatedAt', updated_at
    ) order by sort_order, name), '[]'::json)
    from expert_directions
    where (${includeDisabled ? 'true' : "status = 'active'"});
  `)
}

export async function listAdminExpertDirections(token) {
  await requireAdmin(token, 'expert_profile:read')
  return listExpertDirections({ includeDisabled: true })
}

export async function createAdminExpertDirection(token, payload = {}) {
  const { actor } = await requireAdmin(token, 'expert_profile:write')
  const direction = normalizeExpertDirectionPayload(payload, true)
  const created = await queryJson(`
    insert into expert_directions (
      id, group_name, direction_type, name, description, sort_order, status
    )
    values (
      ${sqlLiteral(direction.id)},
      ${sqlLiteral(direction.groupName)},
      ${sqlLiteral(direction.directionType)},
      ${sqlLiteral(direction.name)},
      ${sqlLiteral(direction.description)},
      ${direction.sortOrder},
      ${sqlLiteral(direction.status)}
    )
    returning json_build_object(
      'id', id,
      'groupName', group_name,
      'directionType', direction_type,
      'name', name,
      'description', description,
      'sortOrder', sort_order,
      'status', status,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `)
  await auditAdmin(actor, 'expert_direction.create', 'expert_direction', created.id, created)
  return created
}

export async function updateAdminExpertDirection(token, directionId, payload = {}) {
  const { actor } = await requireAdmin(token, 'expert_profile:write')
  const existing = await getExpertDirectionById(directionId)
  if (!existing) {
    const error = new Error('专家方向不存在')
    error.statusCode = 404
    error.code = 'expert_direction_not_found'
    throw error
  }
  const direction = normalizeExpertDirectionPayload(payload, false)
  const updated = await queryJson(`
    update expert_directions
    set
      group_name = ${sqlLiteral(direction.groupName)},
      direction_type = ${sqlLiteral(direction.directionType)},
      name = ${sqlLiteral(direction.name)},
      description = ${sqlLiteral(direction.description)},
      sort_order = ${direction.sortOrder},
      status = ${sqlLiteral(direction.status)},
      updated_at = now()
    where id = ${sqlLiteral(directionId)}
    returning json_build_object(
      'id', id,
      'groupName', group_name,
      'directionType', direction_type,
      'name', name,
      'description', description,
      'sortOrder', sort_order,
      'status', status,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `)
  if (!updated) {
    const error = new Error('专家方向不存在')
    error.statusCode = 404
    error.code = 'expert_direction_not_found'
    throw error
  }
  if (existing.name !== updated.name) {
    await replaceExpertDirectionName(existing.name, updated.name)
  }
  await auditAdmin(actor, 'expert_direction.update', 'expert_direction', updated.id, updated)
  return updated
}

export async function deleteAdminExpertDirection(token, directionId) {
  const { actor } = await requireAdmin(token, 'expert_profile:write')
  const updated = await queryJson(`
    update expert_directions
    set status = 'disabled', updated_at = now()
    where id = ${sqlLiteral(directionId)}
    returning json_build_object(
      'id', id,
      'groupName', group_name,
      'directionType', direction_type,
      'name', name,
      'description', description,
      'sortOrder', sort_order,
      'status', status,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `)
  if (!updated) {
    const error = new Error('专家方向不存在')
    error.statusCode = 404
    error.code = 'expert_direction_not_found'
    throw error
  }
  await auditAdmin(actor, 'expert_direction.disable', 'expert_direction', updated.id, updated)
  return { ok: true, item: updated }
}

async function getExpertDirectionById(directionId) {
  return queryJson(`
    select json_build_object(
      'id', id,
      'groupName', group_name,
      'directionType', direction_type,
      'name', name,
      'description', description,
      'sortOrder', sort_order,
      'status', status,
      'createdAt', created_at,
      'updatedAt', updated_at
    )
    from expert_directions
    where id = ${sqlLiteral(directionId)}
    limit 1;
  `)
}

async function replaceExpertDirectionName(previousName, nextName) {
  await executeSql(`
    update expert_profiles
    set
      directions = coalesce((
        select array_agg(case when item = ${sqlLiteral(previousName)} then ${sqlLiteral(nextName)} else item end order by ordinality)
        from unnest(directions) with ordinality as direction_items(item, ordinality)
      ), array[]::text[]),
      specialties = coalesce((
        select array_agg(case when item = ${sqlLiteral(previousName)} then ${sqlLiteral(nextName)} else item end order by ordinality)
        from unnest(specialties) with ordinality as specialty_items(item, ordinality)
      ), array[]::text[]),
      updated_at = now()
    where ${sqlLiteral(previousName)} = any(directions)
       or ${sqlLiteral(previousName)} = any(specialties);
  `)
}

export async function listExperts(filters = {}) {
  const direction = filters.direction || filters.specialty || ''
  const search = String(filters.search || '').trim()
  const availableOnly = String(filters.available || '') === '1' || filters.available === true
  return queryJson(`
    select coalesce(json_agg(${expertProfileJson('e')} order by e.updated_at desc), '[]'::json)
    from expert_profiles e
    where e.status = 'published'
      and e.deleted_at is null
      and (${sqlLiteral(direction)} = '' or ${sqlLiteral(direction)} = any(e.directions) or ${sqlLiteral(direction)} = any(e.specialties))
      and (${sqlLiteral(search)} = '' or e.name ilike '%' || ${sqlLiteral(search)} || '%' or e.organization ilike '%' || ${sqlLiteral(search)} || '%' or e.title ilike '%' || ${sqlLiteral(search)} || '%')
      and (${availableOnly ? 'true' : 'false'} = false or (e.accepting_orders = true and exists (
        select 1 from expert_schedule_slots s
        where s.expert_id = e.id
          and s.status = 'open'
          and s.starts_at >= now()
          and s.starts_at < now() + interval '7 days'
      )));
  `)
}

export async function getExpert(slug) {
  return queryJson(`
    select ${expertProfileJson('e')}
    from expert_profiles e
    where e.status = 'published'
      and e.deleted_at is null
      and (e.slug = ${sqlLiteral(slug)} or e.id::text = ${sqlLiteral(slug)})
    limit 1;
  `)
}

export async function listExpertSlots(expertSlug, filters = {}) {
  await releaseExpiredExpertBookingHolds()
  const expert = await getExpert(expertSlug)
  if (!expert) return []
  return queryJson(`
    select coalesce(json_agg(${expertSlotJson('s')} order by s.starts_at), '[]'::json)
    from expert_schedule_slots s
    where s.expert_id = ${sqlLiteral(expert.id)}::uuid
      and s.starts_at >= coalesce(${sqlLiteral(filters.from || null)}::timestamptz, now())
      and s.starts_at < coalesce(${sqlLiteral(filters.to || null)}::timestamptz, now() + interval '7 days')
      and (${sqlLiteral(filters.status || '')} = '' or s.status = ${sqlLiteral(filters.status || '')});
  `)
}

export async function releaseExpiredExpertBookingHolds() {
  await executeSql(`
    with expired as (
      update expert_bookings
      set status = 'cancelled',
          status_reason = coalesce(nullif(status_reason, ''), '待支付预约超时自动释放'),
          cancelled_at = coalesce(cancelled_at, now()),
          cancelled_by = coalesce(nullif(cancelled_by, ''), 'system'),
          hold_expires_at = null,
          updated_at = now()
      where status = 'pending_payment'
        and hold_expires_at is not null
        and hold_expires_at <= now()
      returning slot_id
    )
    update expert_schedule_slots s
    set status = 'open', updated_at = now()
    where s.id in (select slot_id from expired where slot_id is not null)
      and not exists (
        select 1
        from expert_bookings b
        where b.slot_id = s.id
          and b.status not in ('cancelled', 'rejected')
      );
  `)
  return { ok: true }
}

async function releaseExpertBookingHoldByOrder(orderId, reason = '支付关闭，预约锁位已释放') {
  if (!orderId) return
  await executeSql(`
    with released as (
      update expert_bookings
      set status = 'cancelled',
          status_reason = coalesce(nullif(status_reason, ''), ${sqlLiteral(reason)}),
          cancelled_at = coalesce(cancelled_at, now()),
          cancelled_by = coalesce(nullif(cancelled_by, ''), 'payment'),
          hold_expires_at = null,
          updated_at = now()
      where order_id = ${sqlLiteral(orderId)}::uuid
        and status = 'pending_payment'
      returning slot_id
    )
    update expert_schedule_slots s
    set status = 'open', updated_at = now()
    where s.id in (select slot_id from released where slot_id is not null)
      and not exists (
        select 1
        from expert_bookings b
        where b.slot_id = s.id
          and b.status not in ('cancelled', 'rejected')
      );
  `)
}

async function syncExpertBookingHoldWithPayment(payment) {
  if (!payment?.orderId || !payment?.expiresAt) return
  await executeSql(`
    update expert_bookings
    set hold_expires_at = ${sqlLiteral(payment.expiresAt)}::timestamptz,
        updated_at = now()
    where order_id = ${sqlLiteral(payment.orderId)}::uuid
      and status = 'pending_payment';
  `)
}

async function markExpertSettlementAvailableByBookingId(bookingId) {
  if (!bookingId) return
  await executeSql(`
    update settlement_records sr
    set status = 'available',
        available_amount = greatest(sr.expert_share_amount - sr.locked_amount - sr.withdrawn_amount, 0),
        available_at = coalesce(sr.available_at, now()),
        updated_at = now()
    from expert_bookings b
    where b.id = ${sqlLiteral(bookingId)}::uuid
      and b.order_id = sr.order_id
      and b.expert_id = sr.expert_id
      and sr.status in ('pending_settlement', 'available');
  `)
}

export async function getAgreement(id = 'expert-cooperation-rules', options = {}) {
  return queryJson(`
    select ${agreementJson('a')}
    from site_agreements a
    where a.id = ${sqlLiteral(id)}
      and (${options.includeDraft ? 'true' : 'false'} or a.status = 'published')
    limit 1;
  `)
}

export async function updateAdminAgreement(token, id, payload = {}) {
  const { actor } = await requireAdmin(token, 'site:write')
  const updated = await queryJson(`
    insert into site_agreements (id, title, summary, content, status, updated_by)
    values (
      ${sqlLiteral(id)},
      ${sqlLiteral(payload.title || '大连市大数据研究院专家合作规则')},
      ${sqlLiteral(payload.summary || '')},
      ${sqlLiteral(payload.content || '')},
      ${sqlLiteral(payload.status || 'published')},
      ${sqlLiteral(actor)}
    )
    on conflict (id) do update set
      title = excluded.title,
      summary = excluded.summary,
      content = excluded.content,
      status = excluded.status,
      updated_by = excluded.updated_by,
      updated_at = now()
    returning ${agreementJson('site_agreements')};
  `)
  await auditAdmin(actor, 'agreement.update', 'site_agreement', id, updated)
  return updated
}

export async function getAccountExpertApplication(token) {
  const { user } = await requireUser(token)
  return queryJson(`
    select ${expertApplicationJson('ea', 'u')}
    from expert_applications ea
    join users u on u.id = ea.user_id
    where ea.user_id = ${sqlLiteral(user.id)}::uuid
    order by ea.created_at desc
    limit 1;
  `)
}

export async function submitExpertApplication(token, payload = {}) {
  const { user } = await requireUser(token)
  const application = normalizeExpertApplicationPayload(payload, user)
  const applicationNo = await nextBusinessNo('EXA')
  const created = await queryJson(`
    insert into expert_applications (
      application_no, user_id, passport_no, name, contact_phone, organization, title,
      directions, photo_file_id, photo_url, project_experience,
      representative_achievements, homepage_intro, attachment_file_ids, status
    )
    values (
      ${sqlLiteral(applicationNo)},
      ${sqlLiteral(user.id)}::uuid,
      ${sqlLiteral(user.passportNo)},
      ${sqlLiteral(application.name)},
      ${sqlLiteral(application.contactPhone)},
      ${sqlLiteral(application.organization)},
      ${sqlLiteral(application.title)},
      ${sqlTextArray(application.directions)},
      ${application.photoFileId ? `${sqlLiteral(application.photoFileId)}::uuid` : 'null'},
      ${sqlLiteral(application.photoUrl)},
      ${sqlLiteral(application.projectExperience)},
      ${sqlLiteral(application.representativeAchievements)},
      ${sqlLiteral(application.homepageIntro)},
      ${sqlUuidArray(application.attachmentFileIds)},
      'pending'
    )
    returning json_build_object('id', id);
  `)
  await markUploadedFilesOwner([
    ...(application.photoFileId ? [application.photoFileId] : []),
    ...application.attachmentFileIds,
  ], {
    ownerType: 'expert_material',
    ownerId: user.id,
    visibility: 'private',
    currentOwnerTypes: ['user_upload', 'expert_material'],
    currentOwnerId: user.id,
    allowUnowned: true,
  })
  return getExpertApplicationById(created.id)
}

export async function listAdminExpertApplications(token, filters = {}) {
  await requireAdmin(token, 'expert_application:read')
  return queryJson(`
    select coalesce(json_agg(${expertApplicationJson('ea', 'u')} order by ea.created_at desc), '[]'::json)
    from expert_applications ea
    join users u on u.id = ea.user_id
    where (${sqlLiteral(filters.status || '')} = '' or ea.status = ${sqlLiteral(filters.status || '')});
  `)
}

export async function reviewAdminExpertApplication(token, applicationId, payload = {}) {
  const { actor } = await requireAdmin(token, 'expert_application:review')
  const action = payload.action || payload.status
  if (action !== 'approved' && action !== 'rejected') {
    const error = new Error('审核动作无效')
    error.statusCode = 400
    error.code = 'invalid_expert_review_action'
    throw error
  }
  const application = await getExpertApplicationById(applicationId)
  if (!application) {
    const error = new Error('专家申请不存在')
    error.statusCode = 404
    error.code = 'expert_application_not_found'
    throw error
  }
  if (action === 'rejected') {
    const reason = String(payload.rejectReason || payload.reason || '').trim()
    if (!reason) {
      const error = new Error('审核未通过必须填写原因')
      error.statusCode = 400
      error.code = 'expert_reject_reason_required'
      throw error
    }
    await executeSql(`
      update expert_applications
      set status = 'rejected',
          reject_reason = ${sqlLiteral(reason)},
          reviewed_by = ${sqlLiteral(actor)},
          reviewed_at = now(),
          updated_at = now()
      where id = ${sqlLiteral(application.id)}::uuid;
    `)
    const rejected = await getExpertApplicationById(application.id)
    await auditAdmin(actor, 'expert_application.reject', 'expert_application', rejected.id, rejected)
    return rejected
  }

  const priceAmount = Number(payload.priceAmount ?? payload.price_amount ?? application.reviewPriceAmount ?? 0)
  const shareRatio = Number(payload.shareRatio ?? payload.share_ratio ?? 80)
  if (!Number.isInteger(priceAmount) || priceAmount < 0) {
    const error = new Error('专家价格必须是非负整数分')
    error.statusCode = 400
    error.code = 'invalid_expert_price'
    throw error
  }
  if (!Number.isInteger(shareRatio) || shareRatio < 0 || shareRatio > 100) {
    const error = new Error('专家分成比例必须在 0-100 之间')
    error.statusCode = 400
    error.code = 'invalid_expert_share_ratio'
    throw error
  }

  const slug = String(payload.slug || slugify(application.name)).trim()
  const status = payload.publish === false || payload.status === 'draft' ? 'draft' : 'published'
  const existingProfile = await queryJson(`
    select ${expertProfileJson('e')}
    from expert_profiles e
    where e.user_id = ${sqlLiteral(application.userId)}::uuid
      and e.deleted_at is null
    limit 1;
  `)
  const profile = existingProfile
    ? await updateExpertProfileById(existingProfile.id, {
      ...application,
      slug: payload.slug || existingProfile.slug,
      priceAmount,
      shareRatio,
      status,
      photoUrl: application.photoUrl || existingProfile.photoUrl,
    })
    : await insertExpertProfile({
      userId: application.userId,
      passportNo: application.passportNo,
      slug,
      name: application.name,
      contactPhone: application.contactPhone,
      title: application.title,
      organization: application.organization,
      directions: application.directions,
      specialties: application.directions,
      bio: application.homepageIntro,
      projectExperience: application.projectExperience,
      representativeAchievements: application.representativeAchievements,
      homepageIntro: application.homepageIntro,
      photoUrl: application.photoUrl,
      priceAmount,
      shareRatio,
      status,
      availabilityStatus: 'this-week',
    })

  await executeSql(`
    update expert_applications
    set status = 'approved',
        review_price_amount = ${priceAmount},
        review_share_ratio = ${shareRatio},
        review_publish = ${status === 'published' ? 'true' : 'false'},
        expert_profile_id = ${sqlLiteral(profile.id)}::uuid,
        reviewed_by = ${sqlLiteral(actor)},
        reviewed_at = now(),
        updated_at = now()
    where id = ${sqlLiteral(application.id)}::uuid;
  `)
  await seedExpertDefaultSlots(profile.id)
  const approved = await getExpertApplicationById(application.id)
  await auditAdmin(actor, 'expert_application.approve', 'expert_application', approved.id, { application: approved, profile })
  return approved
}

export async function listAdminExperts(token, filters = {}) {
  await requireAdmin(token, 'expert_profile:read')
  return queryJson(`
    select coalesce(json_agg(expert_json order by (expert_json->>'updatedAt') desc), '[]'::json)
    from (
      select (${expertProfileJson('e')})::jsonb || jsonb_build_object(
        'user', case when u.id is null then null else json_build_object(
          'id', u.id,
          'displayName', u.display_name,
          'phone', u.phone,
          'passportNo', u.passport_no,
          'organization', u.organization,
          'membershipLevel', u.membership_level
        ) end,
        'bookingCount', coalesce((
          select count(*)::int from expert_bookings b where b.expert_id = e.id
        ), 0),
        'completedBookingCount', coalesce((
          select count(*)::int from expert_bookings b where b.expert_id = e.id and b.status = 'completed'
        ), 0)
      ) as expert_json
      from expert_profiles e
      left join users u on u.id = e.user_id
      where e.deleted_at is null
        and (${sqlLiteral(filters.status || '')} = '' or e.status = ${sqlLiteral(filters.status || '')})
        and (${sqlLiteral(filters.direction || '')} = '' or ${sqlLiteral(filters.direction || '')} = any(e.directions))
    ) t;
  `)
}

export async function getAdminExpert(token, expertId) {
  await requireAdmin(token, 'expert_profile:read')
  return queryJson(`
    select (${expertProfileJson('e')})::jsonb || jsonb_build_object(
      'user', case when u.id is null then null else json_build_object(
        'id', u.id,
        'displayName', u.display_name,
        'phone', u.phone,
        'passportNo', u.passport_no,
        'organization', u.organization,
        'membershipLevel', u.membership_level
      ) end,
      'bookingCount', coalesce((
        select count(*)::int from expert_bookings b where b.expert_id = e.id
      ), 0),
      'completedBookingCount', coalesce((
        select count(*)::int from expert_bookings b where b.expert_id = e.id and b.status = 'completed'
      ), 0)
    )
    from expert_profiles e
    left join users u on u.id = e.user_id
    where e.id = ${sqlLiteral(expertId)}::uuid
      and e.deleted_at is null
    limit 1;
  `)
}

export async function createAdminExpert(token, payload) {
  const { actor } = await requireAdmin(token, 'expert_profile:write')
  const expert = normalizeExpertPayload(payload, true)
  let user = null
  if (expert.passportNo) {
    user = await getUserByPassportNo(expert.passportNo)
  }
  if (!user && (payload.createUser || payload.phone)) {
    user = await createUserForManualExpert(payload)
  }
  if (!user && expert.passportNo) {
    const error = new Error('未找到该通行证编号对应的会员账号')
    error.statusCode = 404
    error.code = 'passport_user_not_found'
    throw error
  }
  const created = await insertExpertProfile({
    ...expert,
    userId: user?.id || null,
    passportNo: user?.passportNo || expert.passportNo || '',
  })
  await seedExpertDefaultSlots(created.id)
  await auditAdmin(actor, 'expert.create', 'expert_profile', created.id, created)
  return created
}

export async function updateAdminExpert(token, expertId, payload) {
  const { actor } = await requireAdmin(token, 'expert_profile:write')
  const expert = normalizeExpertPayload(payload, false)
  if (expert.passportNo) {
    const user = await getUserByPassportNo(expert.passportNo)
    if (user) expert.userId = user.id
  }
  const updated = await updateExpertProfileById(expertId, expert)
  await auditAdmin(actor, 'expert.update', 'expert_profile', updated.id, updated)
  return updated
}

export async function deleteAdminExpert(token, expertId) {
  const { actor } = await requireAdmin(token, 'expert_profile:write')
  const deleted = await queryJson(`
    update expert_profiles
    set deleted_at = now(), status = 'offline', updated_at = now()
    where id = ${sqlLiteral(expertId)}::uuid
      and deleted_at is null
    returning ${expertProfileJson('expert_profiles')};
  `)
  if (!deleted) {
    const error = new Error('专家不存在')
    error.statusCode = 404
    error.code = 'expert_not_found'
    throw error
  }
  await auditAdmin(actor, 'expert.delete', 'expert_profile', deleted.id, deleted)
  return { ok: true, item: deleted }
}

export async function setAdminExpertStatus(token, expertId, status) {
  const { actor } = await requireAdmin(token, 'expert_profile:write')
  if (!expertStatuses.has(status)) {
    const error = new Error('专家状态无效')
    error.statusCode = 400
    error.code = 'invalid_expert_status'
    throw error
  }

  const updated = await queryJson(`
    update expert_profiles
    set status = ${sqlLiteral(status)}, updated_at = now()
    where id = ${sqlLiteral(expertId)}::uuid
      and deleted_at is null
    returning ${expertProfileJson('expert_profiles')};
  `)

  if (!updated) {
    const error = new Error('专家不存在')
    error.statusCode = 404
    error.code = 'expert_not_found'
    throw error
  }

  await auditAdmin(actor, `expert.${status}`, 'expert_profile', updated.id, updated)
  return updated
}

export async function setAccountExpertAccepting(token, accepting) {
  const { user } = await requireUser(token)
  const updated = await queryJson(`
    update expert_profiles
    set accepting_orders = ${accepting ? 'true' : 'false'}, updated_at = now()
    where user_id = ${sqlLiteral(user.id)}::uuid
      and deleted_at is null
    returning ${expertProfileJson('expert_profiles')};
  `)
  if (!updated) {
    const error = new Error('专家档案不存在')
    error.statusCode = 404
    error.code = 'expert_profile_not_found'
    throw error
  }
  return updated
}

export async function getAccountExpertProfile(token) {
  const { user } = await requireUser(token)
  return queryJson(`
    select ${expertProfileJson('e')}
    from expert_profiles e
    where e.user_id = ${sqlLiteral(user.id)}::uuid
      and e.deleted_at is null
    limit 1;
  `)
}

export async function listAccountExpertBookings(token) {
  const { user } = await requireUser(token)
  const profile = await getAccountExpertProfile(token)
  const expertFilter = profile ? `or b.expert_id = ${sqlLiteral(profile.id)}::uuid` : ''
  return queryJson(`
    select coalesce(json_agg(${expertBookingJson('b', 'e', 'u')} order by b.starts_at desc), '[]'::json)
    from expert_bookings b
    join expert_profiles e on e.id = b.expert_id
    join users u on u.id = b.user_id
    where b.user_id = ${sqlLiteral(user.id)}::uuid
      ${expertFilter};
  `)
}

export async function getAccountExpertBooking(token, bookingId) {
  const { user } = await requireUser(token)
  const profile = await getAccountExpertProfile(token)
  const expertFilter = profile ? `or b.expert_id = ${sqlLiteral(profile.id)}::uuid` : ''
  const bookingWhere = isUuid(bookingId)
    ? `b.id = ${sqlLiteral(bookingId)}::uuid`
    : `b.booking_no = ${sqlLiteral(bookingId)}`
  return queryJson(`
    select ${expertBookingJson('b', 'e', 'u')}
    from expert_bookings b
    join expert_profiles e on e.id = b.expert_id
    join users u on u.id = b.user_id
    where ${bookingWhere}
      and (b.user_id = ${sqlLiteral(user.id)}::uuid ${expertFilter})
    limit 1;
  `)
}

export async function createExpertBooking(token, payload = {}) {
  const { user } = await requireUser(token)
  await releaseExpiredExpertBookingHolds()
  const slotId = String(payload.slotId || payload.slot_id || '').trim()
  const expertId = String(payload.expertId || payload.expert_id || '').trim()
  const expertWhere = slotId
    ? `s.id = ${sqlLiteral(slotId)}::uuid`
    : `s.expert_id = ${sqlLiteral(expertId)}::uuid and s.starts_at = ${sqlLiteral(payload.startsAt || payload.starts_at)}::timestamptz`
  const slot = await queryJson(`
    select json_build_object(
      'id', s.id,
      'expertId', s.expert_id,
      'startsAt', s.starts_at,
      'endsAt', s.ends_at,
      'status', s.status,
      'expert', ${expertProfileJson('e')}
    )
    from expert_schedule_slots s
    join expert_profiles e on e.id = s.expert_id
    where ${expertWhere}
      and s.status = 'open'
      and s.starts_at >= now()
      and e.status = 'published'
      and e.accepting_orders = true
      and e.deleted_at is null
    limit 1;
  `)
  if (!slot) {
    const error = new Error('该专家当前时段无法预约')
    error.statusCode = 409
    error.code = 'expert_slot_unavailable'
    throw error
  }

  const bookingNo = await nextBusinessNo('EXB')
  const materialFileIds = Array.isArray(payload.materialFileIds || payload.material_file_ids)
    ? (payload.materialFileIds || payload.material_file_ids).filter(isUuid)
    : []
  const booking = await queryJson(`
    insert into expert_bookings (
      booking_no, expert_id, user_id, passport_no, slot_id, starts_at, ends_at,
      contact_name, contact_phone, background, question, material_file_ids, status, hold_expires_at
    )
    values (
      ${sqlLiteral(bookingNo)},
      ${sqlLiteral(slot.expertId)}::uuid,
      ${sqlLiteral(user.id)}::uuid,
      ${sqlLiteral(user.passportNo)},
      ${sqlLiteral(slot.id)}::uuid,
      ${sqlLiteral(slot.startsAt)}::timestamptz,
      ${sqlLiteral(slot.endsAt)}::timestamptz,
      ${sqlLiteral(payload.contactName || payload.contact_name || user.displayName || '平台用户')},
      ${sqlLiteral(payload.contactPhone || payload.contact_phone || user.phone || '')},
      ${sqlLiteral(payload.background || '')},
      ${sqlLiteral(payload.question || payload.message || '')},
      ${sqlUuidArray(materialFileIds)},
      'pending_payment',
      now() + interval '15 minutes'
    )
    returning json_build_object('id', id);
  `)
  await markUploadedFilesOwner(materialFileIds, {
    ownerType: 'expert_material',
    ownerId: user.id,
    visibility: 'private',
    currentOwnerTypes: ['user_upload', 'expert_material'],
    currentOwnerId: user.id,
    allowUnowned: true,
  })
  await executeSql(`
    update expert_schedule_slots
    set status = 'booked', updated_at = now()
    where id = ${sqlLiteral(slot.id)}::uuid;
  `)
  return getExpertBookingById(booking.id)
}

export async function updateExpertBookingStatus(token, bookingId, payload = {}) {
  let actor = 'account'
  let actorType = 'user'
  let shouldAudit = false
  if (payload.admin) {
    const admin = await requireAdmin(token, 'expert_booking:update')
    actor = admin.actor
    actorType = 'admin'
    shouldAudit = true
  } else {
    const { user } = await requireUser(token)
    const accessible = await queryJson(`
      select json_build_object(
        'id', b.id,
        'userId', b.user_id,
        'expertUserId', e.user_id
      )
      from expert_bookings b
      join expert_profiles e on e.id = b.expert_id
      where b.id = ${sqlLiteral(bookingId)}::uuid
        and (b.user_id = ${sqlLiteral(user.id)}::uuid or e.user_id = ${sqlLiteral(user.id)}::uuid)
      limit 1;
    `)
    if (!accessible) {
      const error = new Error('无权操作该预约')
      error.statusCode = 403
      error.code = 'expert_booking_permission_denied'
      throw error
    }
    actor = user.passportNo || user.phone || user.id
    actorType = accessible.expertUserId === user.id && accessible.userId !== user.id ? 'expert' : 'user'
  }
  const status = payload.status
  if (!expertBookingStatuses.has(status)) {
    const error = new Error('预约状态无效')
    error.statusCode = 400
    error.code = 'invalid_expert_booking_status'
    throw error
  }
  const currentForRules = await queryJson(`
    select json_build_object(
      'id', b.id,
      'status', b.status,
      'meetingUrl', b.meeting_url,
      'meetingMethod', b.meeting_method,
      'meetingPlatform', b.meeting_platform,
      'meetingNo', b.meeting_no,
      'meetingNote', b.meeting_note
    )
    from expert_bookings b
    where b.id = ${sqlLiteral(bookingId)}::uuid
    limit 1;
  `)
  if (!currentForRules) {
    const error = new Error('预约不存在')
    error.statusCode = 404
    error.code = 'expert_booking_not_found'
    throw error
  }
  if (!canTransitionExpertBooking(actorType, currentForRules.status, status)) {
    const error = new Error('当前角色不能执行该预约状态流转')
    error.statusCode = 409
    error.code = 'expert_booking_transition_not_allowed'
    throw error
  }
  if (status === 'confirmed') {
    const hasMeetingInfo = Boolean(
      payload.meetingUrl || payload.meeting_url ||
      payload.meetingMethod || payload.meeting_method ||
      payload.meetingPlatform || payload.meeting_platform ||
      payload.meetingNo || payload.meeting_no ||
      payload.meetingNote || payload.meeting_note ||
      currentForRules.meetingUrl ||
      currentForRules.meetingMethod ||
      currentForRules.meetingPlatform ||
      currentForRules.meetingNo ||
      currentForRules.meetingNote
    )
    if (!hasMeetingInfo) {
      const error = new Error('专家确认预约前需要填写会议方式或会议说明')
      error.statusCode = 400
      error.code = 'expert_meeting_info_required'
      throw error
    }
  }
  let rescheduledSlot = null
  if (shouldAudit && (payload.startsAt || payload.starts_at) && (payload.endsAt || payload.ends_at)) {
    const currentBooking = await queryJson(`
      select json_build_object(
        'id', id,
        'expertId', expert_id,
        'slotId', slot_id
      )
      from expert_bookings
      where id = ${sqlLiteral(bookingId)}::uuid
      limit 1;
    `)
    if (!currentBooking) {
      const error = new Error('预约不存在')
      error.statusCode = 404
      error.code = 'expert_booking_not_found'
      throw error
    }
    rescheduledSlot = await queryJson(`
      insert into expert_schedule_slots (expert_id, starts_at, ends_at, status, note)
      values (
        ${sqlLiteral(currentBooking.expertId)}::uuid,
        ${sqlLiteral(payload.startsAt || payload.starts_at)}::timestamptz,
        ${sqlLiteral(payload.endsAt || payload.ends_at)}::timestamptz,
        'booked',
        ${sqlLiteral(payload.note || '管理员改期')}
      )
      on conflict (expert_id, starts_at) do update set
        ends_at = excluded.ends_at,
        status = excluded.status,
        note = excluded.note,
        updated_at = now()
      where expert_schedule_slots.status <> 'booked'
         or expert_schedule_slots.id = ${sqlLiteral(currentBooking.slotId || null)}::uuid
      returning ${expertSlotJson('expert_schedule_slots')};
    `)
    if (!rescheduledSlot) {
      const error = new Error('新时段不可用，无法改期')
      error.statusCode = 409
      error.code = 'expert_reschedule_slot_unavailable'
      throw error
    }
    if (currentBooking.slotId && currentBooking.slotId !== rescheduledSlot.id) {
      await executeSql(`
        update expert_schedule_slots
        set status = 'open', updated_at = now()
        where id = ${sqlLiteral(currentBooking.slotId)}::uuid;
      `)
    }
  }
  const updated = await queryJson(`
    update expert_bookings
    set status = ${sqlLiteral(status)},
        status_reason = coalesce(${sqlLiteral(payload.statusReason || payload.reason)}, status_reason),
        meeting_url = coalesce(${sqlLiteral(payload.meetingUrl || payload.meeting_url)}, meeting_url),
        meeting_method = coalesce(${sqlLiteral(payload.meetingMethod || payload.meeting_method)}, meeting_method),
        meeting_platform = coalesce(${sqlLiteral(payload.meetingPlatform || payload.meeting_platform)}, meeting_platform),
        meeting_no = coalesce(${sqlLiteral(payload.meetingNo || payload.meeting_no)}, meeting_no),
        meeting_password = coalesce(${sqlLiteral(payload.meetingPassword || payload.meeting_password)}, meeting_password),
        meeting_note = coalesce(${sqlLiteral(payload.meetingNote || payload.meeting_note)}, meeting_note),
        meeting_internal_note = coalesce(${sqlLiteral(payload.meetingInternalNote || payload.meeting_internal_note)}, meeting_internal_note),
        meeting_updated_at = case
          when ${payload.meetingUrl || payload.meeting_url || payload.meetingMethod || payload.meeting_method || payload.meetingPlatform || payload.meeting_platform || payload.meetingNo || payload.meeting_no || payload.meetingPassword || payload.meeting_password || payload.meetingNote || payload.meeting_note ? 'true' : 'false'} then now()
          else meeting_updated_at
        end,
        slot_id = coalesce(${rescheduledSlot ? `${sqlLiteral(rescheduledSlot.id)}::uuid` : 'null'}, slot_id),
        starts_at = coalesce(${rescheduledSlot ? `${sqlLiteral(rescheduledSlot.startsAt)}::timestamptz` : 'null'}, starts_at),
        ends_at = coalesce(${rescheduledSlot ? `${sqlLiteral(rescheduledSlot.endsAt)}::timestamptz` : 'null'}, ends_at),
        hold_expires_at = case when ${sqlLiteral(status)} <> 'pending_payment' then null else hold_expires_at end,
        cancelled_at = case when ${sqlLiteral(status)} = 'cancelled' and cancelled_at is null then now() else cancelled_at end,
        cancelled_by = case when ${sqlLiteral(status)} = 'cancelled' and cancelled_by = '' then ${sqlLiteral(actor)} else cancelled_by end,
        completion_submitted_at = case when ${sqlLiteral(status)} = 'pending_user_confirm' and completion_submitted_at is null then now() else completion_submitted_at end,
        user_confirmed_at = case when ${sqlLiteral(status)} = 'completed' and user_confirmed_at is null then now() else user_confirmed_at end,
        disputed_at = case when ${sqlLiteral(status)} = 'disputed' and disputed_at is null then now() else disputed_at end,
        dispute_reason = case when ${sqlLiteral(status)} = 'disputed' then coalesce(${sqlLiteral(payload.disputeReason || payload.dispute_reason || payload.reason)}, dispute_reason) else dispute_reason end,
        completed_at = case when ${sqlLiteral(status)} = 'completed' and completed_at is null then now() else completed_at end,
        updated_at = now()
    where id = ${sqlLiteral(bookingId)}::uuid
    returning json_build_object('id', id);
  `)
  if (!updated) {
    const error = new Error('预约不存在')
    error.statusCode = 404
    error.code = 'expert_booking_not_found'
    throw error
  }
  if (status === 'cancelled' || status === 'rejected') {
    await executeSql(`
      update expert_schedule_slots s
      set status = 'open', updated_at = now()
      from expert_bookings b
      where b.slot_id = s.id
        and b.id = ${sqlLiteral(bookingId)}::uuid;
    `)
  }
  if (status === 'completed') {
    await markExpertSettlementAvailableByBookingId(bookingId)
  }
  if (shouldAudit) {
    await auditAdmin(actor, 'expert_booking.update', 'expert_booking', bookingId, payload)
  }
  const meetingChanged = Boolean(payload.meetingUrl || payload.meeting_url || payload.meetingMethod || payload.meeting_method || payload.meetingPlatform || payload.meeting_platform || payload.meetingNo || payload.meeting_no || payload.meetingPassword || payload.meeting_password || payload.meetingNote || payload.meeting_note)
  if (meetingChanged || status === 'confirmed') {
    const bookingNotice = await queryJson(`
      select json_build_object(
        'userId', b.user_id,
        'expertUserId', e.user_id,
        'bookingNo', b.booking_no,
        'expertName', e.name,
        'startsAt', b.starts_at,
        'meetingPlatform', b.meeting_platform,
        'meetingNo', b.meeting_no
      )
      from expert_bookings b
      join expert_profiles e on e.id = b.expert_id
      where b.id = ${sqlLiteral(bookingId)}::uuid
      limit 1;
    `)
    if (bookingNotice) {
      const noticePayload = {
        title: '专家会议已确认',
        body: `${bookingNotice.expertName || '专家'}已确认会议，会议方式和入会信息已更新。`,
        actionUrl: '/account/expert-bookings',
        bookingNo: bookingNotice.bookingNo,
        meetingPlatform: bookingNotice.meetingPlatform,
        meetingNo: bookingNotice.meetingNo,
      }
      await queueServiceNotice(bookingNotice.userId, 'expert_booking', 'expert_meeting_updated', noticePayload, `expert_meeting_updated:user:${bookingId}:${Date.now()}`)
      if (bookingNotice.expertUserId && bookingNotice.expertUserId !== bookingNotice.userId) {
        await queueServiceNotice(bookingNotice.expertUserId, 'expert_booking', 'expert_meeting_updated', {
          ...noticePayload,
          title: '专家预约会议信息已更新',
          body: `预约 ${bookingNotice.bookingNo || ''} 的会议信息已更新。`,
          actionUrl: '/account/expert-home',
        }, `expert_meeting_updated:expert:${bookingId}:${Date.now()}`)
      }
    }
  }
  return getExpertBookingById(bookingId)
}

export async function confirmAccountExpertBookingCompletion(token, bookingId) {
  const { user } = await requireUser(token)
  const current = await queryJson(`
    select json_build_object('id', id, 'status', status)
    from expert_bookings
    where id = ${sqlLiteral(bookingId)}::uuid
      and user_id = ${sqlLiteral(user.id)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('预约不存在')
    error.statusCode = 404
    error.code = 'expert_booking_not_found'
    throw error
  }
  if (current.status !== 'pending_user_confirm') {
    const error = new Error('该预约当前不能确认完成')
    error.statusCode = 409
    error.code = 'expert_booking_not_confirmable'
    throw error
  }
  return updateExpertBookingStatus(token, bookingId, { status: 'completed' })
}

export async function disputeAccountExpertBookingCompletion(token, bookingId, payload = {}) {
  const { user } = await requireUser(token)
  const reason = String(payload.reason || payload.disputeReason || payload.dispute_reason || '').trim()
  if (!reason) {
    const error = new Error('请填写争议原因')
    error.statusCode = 400
    error.code = 'expert_dispute_reason_required'
    throw error
  }
  const current = await queryJson(`
    select json_build_object('id', id, 'status', status)
    from expert_bookings
    where id = ${sqlLiteral(bookingId)}::uuid
      and user_id = ${sqlLiteral(user.id)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('预约不存在')
    error.statusCode = 404
    error.code = 'expert_booking_not_found'
    throw error
  }
  if (current.status !== 'pending_user_confirm') {
    const error = new Error('该预约当前不能发起争议')
    error.statusCode = 409
    error.code = 'expert_booking_not_disputable'
    throw error
  }
  return updateExpertBookingStatus(token, bookingId, { status: 'disputed', disputeReason: reason })
}

export async function listAdminExpertBookings(token, filters = {}) {
  await requireAdmin(token, 'expert_booking:read')
  const expertId = filters.expertId || filters.expert_id || ''
  return queryJson(`
    select coalesce(json_agg(${expertBookingJson('b', 'e', 'u')} order by b.starts_at desc), '[]'::json)
    from expert_bookings b
    join expert_profiles e on e.id = b.expert_id
    join users u on u.id = b.user_id
    where (${sqlLiteral(filters.status || '')} = '' or b.status = ${sqlLiteral(filters.status || '')})
      and (nullif(${sqlLiteral(expertId)}, '') is null or b.expert_id = nullif(${sqlLiteral(expertId)}, '')::uuid);
  `)
}

export async function upsertExpertSlot(token, payload = {}) {
  const { actor } = await requireAdmin(token, 'expert_schedule:write')
  const status = payload.status || 'open'
  if (!expertSlotStatuses.has(status)) {
    const error = new Error('排期状态无效')
    error.statusCode = 400
    error.code = 'invalid_expert_slot_status'
    throw error
  }
  const expertId = payload.expertId || payload.expert_id
  const startsAt = payload.startsAt || payload.starts_at
  const endsAt = payload.endsAt || payload.ends_at
  if (!expertId || !startsAt || !endsAt) {
    const error = new Error('排期需要专家、开始时间和结束时间')
    error.statusCode = 400
    error.code = 'invalid_expert_slot'
    throw error
  }
  const slot = await queryJson(`
    insert into expert_schedule_slots (expert_id, starts_at, ends_at, status, note)
    values (
      ${sqlLiteral(expertId)}::uuid,
      ${sqlLiteral(startsAt)}::timestamptz,
      ${sqlLiteral(endsAt)}::timestamptz,
      ${sqlLiteral(status)},
      ${sqlLiteral(payload.note || '')}
    )
    on conflict (expert_id, starts_at) do update set
      ends_at = excluded.ends_at,
      status = excluded.status,
      note = excluded.note,
      updated_at = now()
    returning ${expertSlotJson('expert_schedule_slots')};
  `)
  await auditAdmin(actor, 'expert_slot.upsert', 'expert_schedule_slot', slot.id, slot)
  return slot
}

export async function listAccountExpertSlots(token, filters = {}) {
  await releaseExpiredExpertBookingHolds()
  const profile = await getAccountExpertProfile(token)
  if (!profile) {
    const error = new Error('专家档案不存在')
    error.statusCode = 404
    error.code = 'expert_profile_not_found'
    throw error
  }
  return queryJson(`
    select coalesce(json_agg(${expertSlotJson('s')} order by s.starts_at), '[]'::json)
    from expert_schedule_slots s
    where s.expert_id = ${sqlLiteral(profile.id)}::uuid
      and s.starts_at >= coalesce(${sqlLiteral(filters.from || null)}::timestamptz, now())
      and s.starts_at < coalesce(${sqlLiteral(filters.to || null)}::timestamptz, now() + interval '7 days');
  `)
}

export async function upsertAccountExpertSlot(token, payload = {}) {
  const profile = await getAccountExpertProfile(token)
  if (!profile) {
    const error = new Error('专家档案不存在')
    error.statusCode = 404
    error.code = 'expert_profile_not_found'
    throw error
  }
  const requestedStatus = payload.status === 'closed' ? 'closed' : 'open'
  const startsAt = payload.startsAt || payload.starts_at
  const endsAt = payload.endsAt || payload.ends_at
  if (!startsAt || !endsAt) {
    const error = new Error('排期需要开始时间和结束时间')
    error.statusCode = 400
    error.code = 'invalid_expert_slot'
    throw error
  }
  const slot = await queryJson(`
    insert into expert_schedule_slots (expert_id, starts_at, ends_at, status, note)
    values (
      ${sqlLiteral(profile.id)}::uuid,
      ${sqlLiteral(startsAt)}::timestamptz,
      ${sqlLiteral(endsAt)}::timestamptz,
      ${sqlLiteral(requestedStatus)},
      ${sqlLiteral(payload.note || '')}
    )
    on conflict (expert_id, starts_at) do update set
      ends_at = excluded.ends_at,
      status = excluded.status,
      note = excluded.note,
      updated_at = now()
    where expert_schedule_slots.status <> 'booked'
    returning ${expertSlotJson('expert_schedule_slots')};
  `)
  if (!slot) {
    const error = new Error('已约时段不能修改')
    error.statusCode = 409
    error.code = 'expert_slot_booked'
    throw error
  }
  return slot
}

export async function listAdminExpertSlots(token, filters = {}) {
  await requireAdmin(token, 'expert_schedule:read')
  await releaseExpiredExpertBookingHolds()
  const expertId = filters.expertId || filters.expert_id || ''
  return queryJson(`
    select coalesce(json_agg(${expertSlotJson('s')} order by s.starts_at), '[]'::json)
    from expert_schedule_slots s
    where (nullif(${sqlLiteral(expertId)}, '') is null or s.expert_id = nullif(${sqlLiteral(expertId)}, '')::uuid)
      and s.starts_at >= coalesce(${sqlLiteral(filters.from || null)}::timestamptz, now())
      and s.starts_at < coalesce(${sqlLiteral(filters.to || null)}::timestamptz, now() + interval '7 days');
  `)
}

async function resolveExpertBookingActor(token, bookingId, admin = false) {
  if (admin) {
    const adminActor = await requireAdmin(token, 'expert_booking:update')
    return { ...adminActor, requesterType: 'admin', user: null }
  }
  const { user } = await requireUser(token)
  const access = await queryJson(`
    select json_build_object(
      'bookingId', b.id,
      'userId', b.user_id,
      'expertUserId', e.user_id
    )
    from expert_bookings b
    join expert_profiles e on e.id = b.expert_id
    where b.id = ${sqlLiteral(bookingId)}::uuid
      and (b.user_id = ${sqlLiteral(user.id)}::uuid or e.user_id = ${sqlLiteral(user.id)}::uuid)
    limit 1;
  `)
  if (!access) {
    const error = new Error('无权操作该预约')
    error.statusCode = 403
    error.code = 'expert_booking_permission_denied'
    throw error
  }
  return {
    actor: user.passportNo || user.phone || user.id,
    user,
    requesterType: access.userId === user.id ? 'user' : 'expert',
  }
}

export async function createExpertBookingReschedule(token, bookingId, payload = {}) {
  const admin = Boolean(payload.admin)
  const { actor, requesterType } = await resolveExpertBookingActor(token, bookingId, admin)
  const startsAt = payload.startsAt || payload.starts_at
  const endsAt = payload.endsAt || payload.ends_at
  if (!startsAt || !endsAt) {
    const error = new Error('改期需要新的开始和结束时间')
    error.statusCode = 400
    error.code = 'invalid_expert_reschedule_time'
    throw error
  }
  const current = await queryJson(`
    select json_build_object(
      'id', id,
      'status', status,
      'startsAt', starts_at,
      'endsAt', ends_at
    )
    from expert_bookings
    where id = ${sqlLiteral(bookingId)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('预约不存在')
    error.statusCode = 404
    error.code = 'expert_booking_not_found'
    throw error
  }
  if (!['pending_expert', 'confirmed'].includes(current.status)) {
    const error = new Error('当前预约状态不可改期')
    error.statusCode = 409
    error.code = 'expert_booking_not_reschedulable'
    throw error
  }
  await executeSql(`
    update expert_booking_changes
    set status = 'cancelled', handled_by = ${sqlLiteral(actor)}, handled_note = '新的改期申请已创建', handled_at = now()
    where booking_id = ${sqlLiteral(bookingId)}::uuid
      and status in ('requested', 'proposed');
  `)
  const status = requesterType === 'expert' ? 'proposed' : 'requested'
  await queryJson(`
    insert into expert_booking_changes (
      booking_id, requester_type, change_type, requested_starts_at, requested_ends_at,
      reason, status, requested_by_user_at, requested_by_expert_at,
      user_confirmed_at, expert_confirmed_at
    )
    values (
      ${sqlLiteral(bookingId)}::uuid,
      ${sqlLiteral(requesterType)},
      'reschedule',
      ${sqlLiteral(startsAt)}::timestamptz,
      ${sqlLiteral(endsAt)}::timestamptz,
      ${sqlLiteral(payload.reason || payload.note || '')},
      ${sqlLiteral(status)},
      case when ${requesterType === 'user' ? 'true' : 'false'} then now() else null end,
      case when ${requesterType === 'expert' ? 'true' : 'false'} then now() else null end,
      case when ${requesterType === 'user' ? 'true' : 'false'} then now() else null end,
      case when ${requesterType === 'expert' ? 'true' : 'false'} then now() else null end
    )
    returning json_build_object('id', id);
  `)
  const notice = await queryJson(`
    select json_build_object(
      'userId', b.user_id,
      'expertUserId', e.user_id,
      'bookingNo', b.booking_no,
      'expertName', e.name
    )
    from expert_bookings b
    join expert_profiles e on e.id = b.expert_id
    where b.id = ${sqlLiteral(bookingId)}::uuid
    limit 1;
  `)
  if (notice) {
    const targetUserId = requesterType === 'user' ? notice.expertUserId : notice.userId
    await queueServiceNotice(targetUserId, 'expert_booking', 'expert_reschedule_requested', {
      title: '专家预约改期待确认',
      body: `${requesterType === 'user' ? '用户' : requesterType === 'expert' ? '专家' : '管理员'}发起了改期申请，请确认新时间。`,
      actionUrl: requesterType === 'user' ? '/account/expert-home' : '/account/expert-bookings',
      bookingNo: notice.bookingNo,
      startsAt,
      endsAt,
      reason: payload.reason || payload.note || '',
    }, `expert_reschedule_requested:${bookingId}:${Date.now()}`)
  }
  if (admin) await auditAdmin(actor, 'expert_booking.reschedule.request', 'expert_booking', bookingId, payload)
  return getExpertBookingById(bookingId)
}

export async function respondExpertBookingReschedule(token, bookingId, changeId, payload = {}) {
  const admin = Boolean(payload.admin)
  const { actor, requesterType } = await resolveExpertBookingActor(token, bookingId, admin)
  const action = payload.action || payload.status || 'accepted'
  if (!expertBookingChangeStatuses.has(action)) {
    const error = new Error('改期操作无效')
    error.statusCode = 400
    error.code = 'invalid_expert_reschedule_action'
    throw error
  }
  const change = await queryJson(`
    select json_build_object(
      'id', c.id,
      'bookingId', c.booking_id,
      'status', c.status,
      'startsAt', c.requested_starts_at,
      'endsAt', c.requested_ends_at,
      'userConfirmedAt', c.user_confirmed_at,
      'expertConfirmedAt', c.expert_confirmed_at,
      'booking', json_build_object(
        'id', b.id,
        'expertId', b.expert_id,
        'slotId', b.slot_id
      )
    )
    from expert_booking_changes c
    join expert_bookings b on b.id = c.booking_id
    where c.id = ${sqlLiteral(changeId)}::uuid
      and c.booking_id = ${sqlLiteral(bookingId)}::uuid
      and c.change_type = 'reschedule'
    limit 1;
  `)
  if (!change) {
    const error = new Error('改期记录不存在')
    error.statusCode = 404
    error.code = 'expert_reschedule_not_found'
    throw error
  }
  if (action === 'rejected' || action === 'cancelled') {
    await executeSql(`
      update expert_booking_changes
      set status = ${sqlLiteral(action)}, handled_by = ${sqlLiteral(actor)}, handled_note = ${sqlLiteral(payload.note || '')}, handled_at = now()
      where id = ${sqlLiteral(changeId)}::uuid;
    `)
    const rejectedNotice = await queryJson(`
      select json_build_object(
        'userId', b.user_id,
        'expertUserId', e.user_id,
        'bookingNo', b.booking_no
      )
      from expert_bookings b
      join expert_profiles e on e.id = b.expert_id
      where b.id = ${sqlLiteral(bookingId)}::uuid
      limit 1;
    `)
    if (rejectedNotice) {
      const payloadJson = {
        title: '专家预约改期未通过',
        body: '改期申请已被拒绝，原预约时间保持不变。',
        actionUrl: '/account/expert-bookings',
        bookingNo: rejectedNotice.bookingNo,
      }
      await queueServiceNotice(rejectedNotice.userId, 'expert_booking', 'expert_reschedule_rejected', payloadJson, `expert_reschedule_rejected:user:${bookingId}:${Date.now()}`)
      if (rejectedNotice.expertUserId && rejectedNotice.expertUserId !== rejectedNotice.userId) {
        await queueServiceNotice(rejectedNotice.expertUserId, 'expert_booking', 'expert_reschedule_rejected', { ...payloadJson, actionUrl: '/account/expert-home' }, `expert_reschedule_rejected:expert:${bookingId}:${Date.now()}`)
      }
    }
    return getExpertBookingById(bookingId)
  }
  const userConfirmed = requesterType === 'user' || admin || Boolean(change.userConfirmedAt)
  const expertConfirmed = requesterType === 'expert' || admin || Boolean(change.expertConfirmedAt)
  const shouldApply = userConfirmed && expertConfirmed
  await executeSql(`
    update expert_booking_changes
    set user_confirmed_at = case when ${userConfirmed ? 'true' : 'false'} and user_confirmed_at is null then now() else user_confirmed_at end,
        expert_confirmed_at = case when ${expertConfirmed ? 'true' : 'false'} and expert_confirmed_at is null then now() else expert_confirmed_at end,
        admin_note = coalesce(${sqlLiteral(payload.adminNote || payload.admin_note)}, admin_note),
        handled_by = ${sqlLiteral(actor)},
        handled_note = ${sqlLiteral(payload.note || '')},
        handled_at = now()
    where id = ${sqlLiteral(changeId)}::uuid;
  `)
  if (shouldApply) {
    const slot = await queryJson(`
      insert into expert_schedule_slots (expert_id, starts_at, ends_at, status, note)
      values (
        ${sqlLiteral(change.booking.expertId)}::uuid,
        ${sqlLiteral(change.startsAt)}::timestamptz,
        ${sqlLiteral(change.endsAt)}::timestamptz,
        'booked',
        ${sqlLiteral(payload.note || '双方确认改期')}
      )
      on conflict (expert_id, starts_at) do update set
        ends_at = excluded.ends_at,
        status = excluded.status,
        note = excluded.note,
        updated_at = now()
      where expert_schedule_slots.status <> 'booked'
         or expert_schedule_slots.id = ${sqlLiteral(change.booking.slotId || null)}::uuid
      returning ${expertSlotJson('expert_schedule_slots')};
    `)
    if (!slot) {
      const error = new Error('新时段不可用，无法改期')
      error.statusCode = 409
      error.code = 'expert_reschedule_slot_unavailable'
      throw error
    }
    if (change.booking.slotId && change.booking.slotId !== slot.id) {
      await executeSql(`
        update expert_schedule_slots
        set status = 'open', updated_at = now()
        where id = ${sqlLiteral(change.booking.slotId)}::uuid;
      `)
    }
    await executeSql(`
      update expert_bookings
      set slot_id = ${sqlLiteral(slot.id)}::uuid,
          starts_at = ${sqlLiteral(slot.startsAt)}::timestamptz,
          ends_at = ${sqlLiteral(slot.endsAt)}::timestamptz,
          updated_at = now()
      where id = ${sqlLiteral(bookingId)}::uuid;

      update expert_booking_changes
      set status = 'accepted', applied_at = now()
      where id = ${sqlLiteral(changeId)}::uuid;
    `)
    const acceptedNotice = await queryJson(`
      select json_build_object(
        'userId', b.user_id,
        'expertUserId', e.user_id,
        'bookingNo', b.booking_no,
        'startsAt', b.starts_at
      )
      from expert_bookings b
      join expert_profiles e on e.id = b.expert_id
      where b.id = ${sqlLiteral(bookingId)}::uuid
      limit 1;
    `)
    if (acceptedNotice) {
      const payloadJson = {
        title: '专家预约已改期',
        body: '双方已确认新时间，预约时间已更新。',
        actionUrl: '/account/expert-bookings',
        bookingNo: acceptedNotice.bookingNo,
        startsAt: acceptedNotice.startsAt,
      }
      await queueServiceNotice(acceptedNotice.userId, 'expert_booking', 'expert_reschedule_accepted', payloadJson, `expert_reschedule_accepted:user:${bookingId}:${Date.now()}`)
      if (acceptedNotice.expertUserId && acceptedNotice.expertUserId !== acceptedNotice.userId) {
        await queueServiceNotice(acceptedNotice.expertUserId, 'expert_booking', 'expert_reschedule_accepted', { ...payloadJson, actionUrl: '/account/expert-home' }, `expert_reschedule_accepted:expert:${bookingId}:${Date.now()}`)
      }
    }
  }
  if (admin) await auditAdmin(actor, 'expert_booking.reschedule.respond', 'expert_booking', bookingId, payload)
  return getExpertBookingById(bookingId)
}

export async function createExpertWithdrawal(token, payload = {}) {
  const { user } = await requireUser(token)
  const profile = await getAccountExpertProfile(token)
  if (!profile) {
    const error = new Error('专家档案不存在')
    error.statusCode = 404
    error.code = 'expert_profile_not_found'
    throw error
  }
  const amount = Number(payload.amount || 0)
  if (!Number.isInteger(amount) || amount <= 0) {
    const error = new Error('提现金额必须大于 0')
    error.statusCode = 400
    error.code = 'invalid_withdrawal_amount'
    throw error
  }
  const wallet = await getAccountExpertWallet(token)
  if (amount > Number(wallet.availableAmount || 0)) {
    const error = new Error('提现金额超过可用余额')
    error.statusCode = 409
    error.code = 'expert_withdrawal_insufficient_balance'
    throw error
  }
  const settlementItems = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', sr.id,
      'availableAmount', sr.available_amount
    ) order by sr.available_at nulls last, sr.created_at), '[]'::json)
    from settlement_records sr
    where sr.expert_id = ${sqlLiteral(profile.id)}::uuid
      and sr.status = 'available'
      and sr.available_amount > 0;
  `)
  const withdrawalNo = await nextBusinessNo('WDR')
  const withdrawal = await queryJson(`
    insert into expert_withdrawals (
      withdrawal_no, expert_id, user_id, passport_no, amount, payee_name, bank_account, bank_name
    )
    values (
      ${sqlLiteral(withdrawalNo)},
      ${sqlLiteral(profile.id)}::uuid,
      ${sqlLiteral(user.id)}::uuid,
      ${sqlLiteral(user.passportNo)},
      ${amount},
      ${sqlLiteral(payload.payeeName || payload.payee_name || '')},
      ${sqlLiteral(payload.bankAccount || payload.bank_account || '')},
      ${sqlLiteral(payload.bankName || payload.bank_name || '')}
    )
    returning json_build_object('id', id);
  `)
  let remaining = amount
  for (const item of settlementItems || []) {
    if (remaining <= 0) break
    const itemAmount = Math.min(Number(item.availableAmount || 0), remaining)
    if (itemAmount <= 0) continue
    await executeSql(`
      update settlement_records
      set available_amount = greatest(available_amount - ${itemAmount}, 0),
          locked_amount = locked_amount + ${itemAmount},
          status = case when available_amount - ${itemAmount} > 0 then 'available' else 'withdrawal_locked' end,
          updated_at = now()
      where id = ${sqlLiteral(item.id)}::uuid
        and available_amount >= ${itemAmount};

      insert into expert_withdrawal_items (withdrawal_id, settlement_record_id, amount)
      values (
        ${sqlLiteral(withdrawal.id)}::uuid,
        ${sqlLiteral(item.id)}::uuid,
        ${itemAmount}
      )
      on conflict (withdrawal_id, settlement_record_id) do nothing;
    `)
    remaining -= itemAmount
  }
  return getExpertWithdrawalById(withdrawal.id)
}

export async function getAccountExpertWallet(token) {
  const profile = await getAccountExpertProfile(token)
  if (!profile) {
    const error = new Error('专家档案不存在')
    error.statusCode = 404
    error.code = 'expert_profile_not_found'
    throw error
  }
  return queryJson(`
    select json_build_object(
      'expertId', ${sqlLiteral(profile.id)}::uuid,
      'availableAmount', coalesce(sum(sr.available_amount) filter (where sr.status = 'available'), 0)::int,
      'lockedAmount', coalesce(sum(sr.locked_amount), 0)::int,
      'withdrawnAmount', coalesce(sum(sr.withdrawn_amount), 0)::int,
      'pendingSettlementAmount', coalesce(sum(sr.expert_share_amount) filter (where sr.status = 'pending_settlement'), 0)::int,
      'items', coalesce(json_agg(json_build_object(
        'id', sr.id,
        'settlementNo', sr.settlement_no,
        'orderId', sr.order_id,
        'grossAmount', sr.gross_amount,
        'expertShareAmount', sr.expert_share_amount,
        'availableAmount', sr.available_amount,
        'lockedAmount', sr.locked_amount,
        'withdrawnAmount', sr.withdrawn_amount,
        'status', sr.status,
        'availableAt', sr.available_at,
        'createdAt', sr.created_at,
        'updatedAt', sr.updated_at
      ) order by sr.created_at desc) filter (where sr.id is not null), '[]'::json)
    )
    from settlement_records sr
    where sr.expert_id = ${sqlLiteral(profile.id)}::uuid;
  `)
}

export async function listExpertWithdrawals(token) {
  const { user } = await requireUser(token)
  return queryJson(`
    select coalesce(json_agg(${expertWithdrawalJson('w', 'e', 'u')} order by w.created_at desc), '[]'::json)
    from expert_withdrawals w
    join expert_profiles e on e.id = w.expert_id
    join users u on u.id = w.user_id
    where w.user_id = ${sqlLiteral(user.id)}::uuid;
  `)
}

export async function listAdminExpertWithdrawals(token, filters = {}) {
  await requireAdmin(token, 'settlement:read')
  return queryJson(`
    select coalesce(json_agg(${expertWithdrawalJson('w', 'e', 'u')} order by w.created_at desc), '[]'::json)
    from expert_withdrawals w
    join expert_profiles e on e.id = w.expert_id
    join users u on u.id = w.user_id
    where (${sqlLiteral(filters.status || '')} = '' or w.status = ${sqlLiteral(filters.status || '')});
  `)
}

export async function updateAdminExpertWithdrawal(token, withdrawalId, payload = {}) {
  const { actor } = await requireAdmin(token, 'withdrawal:update')
  const status = payload.status || 'completed'
  if (!expertWithdrawalStatuses.has(status)) {
    const error = new Error('提现状态无效')
    error.statusCode = 400
    error.code = 'invalid_withdrawal_status'
    throw error
  }
  const current = await getExpertWithdrawalById(withdrawalId)
  if (!current) {
    const error = new Error('提现申请不存在')
    error.statusCode = 404
    error.code = 'withdrawal_not_found'
    throw error
  }
  if (current.status !== 'pending') {
    const error = new Error('该提现申请已经处理')
    error.statusCode = 409
    error.code = 'withdrawal_already_handled'
    throw error
  }
  const proofFileId = isUuid(payload.paymentProofFileId || payload.payment_proof_file_id)
    ? String(payload.paymentProofFileId || payload.payment_proof_file_id)
    : null
  const paidBank = String(payload.paidBank || payload.paid_bank || current.paidBank || '').trim()
  const paymentSerialNo = String(payload.paymentSerialNo || payload.payment_serial_no || current.paymentSerialNo || '').trim()
  const currentProofFileId = isUuid(current.paymentProofFileId) ? current.paymentProofFileId : ''
  const adminNote = String(payload.adminNote || payload.admin_note || current.adminNote || '').trim()
  const payoutNote = String(payload.payoutNote || payload.payout_note || current.payoutNote || '').trim()
  if (status === 'completed') {
    if (!paidBank) {
      const error = new Error('完成提现必须填写付款银行')
      error.statusCode = 400
      error.code = 'withdrawal_paid_bank_required'
      throw error
    }
    if (!paymentSerialNo) {
      const error = new Error('完成提现必须填写付款流水号')
      error.statusCode = 400
      error.code = 'withdrawal_payment_serial_required'
      throw error
    }
    if (!proofFileId && !currentProofFileId) {
      const error = new Error('完成提现必须上传付款凭证')
      error.statusCode = 400
      error.code = 'withdrawal_payment_proof_required'
      throw error
    }
  }
  if (status === 'rejected' && !adminNote && !payoutNote) {
    const error = new Error('驳回提现必须填写处理备注')
    error.statusCode = 400
    error.code = 'withdrawal_reject_note_required'
    throw error
  }
  const updated = await queryJson(`
    update expert_withdrawals
    set status = ${sqlLiteral(status)},
        admin_note = coalesce(${sqlLiteral(adminNote)}, admin_note),
        paid_at = case when ${sqlLiteral(status)} = 'completed' then coalesce(${payload.paidAt || payload.paid_at ? `${sqlLiteral(payload.paidAt || payload.paid_at)}::timestamptz` : 'null'}, now()) else paid_at end,
        paid_bank = coalesce(${sqlLiteral(paidBank)}, paid_bank),
        payment_serial_no = coalesce(${sqlLiteral(paymentSerialNo)}, payment_serial_no),
        payment_proof_file_id = coalesce(${proofFileId ? `${sqlLiteral(proofFileId)}::uuid` : 'null'}, payment_proof_file_id),
        payout_note = coalesce(${sqlLiteral(payoutNote)}, payout_note),
        handled_by = ${sqlLiteral(actor)},
        handled_at = now(),
        updated_at = now()
    where id = ${sqlLiteral(withdrawalId)}::uuid
    returning json_build_object('id', id);
  `)
  if (!updated) {
    const error = new Error('提现申请不存在')
    error.statusCode = 404
    error.code = 'withdrawal_not_found'
    throw error
  }
  if (proofFileId) {
    await markUploadedFilesOwner([proofFileId], {
      ownerType: 'withdrawal_proof',
      ownerId: withdrawalId,
      visibility: 'private',
      currentOwnerTypes: ['withdrawal_proof'],
      currentOwnerId: withdrawalId,
      allowUnowned: true,
    })
  }
  if (status === 'completed') {
    await executeSql(`
      update settlement_records sr
      set locked_amount = greatest(sr.locked_amount - ewi.amount, 0),
          withdrawn_amount = sr.withdrawn_amount + ewi.amount,
          status = case
            when sr.locked_amount - ewi.amount > 0 then 'withdrawal_locked'
            when sr.available_amount > 0 then 'available'
            else 'withdrawn'
          end,
          updated_at = now()
      from expert_withdrawal_items ewi
      where ewi.withdrawal_id = ${sqlLiteral(withdrawalId)}::uuid
        and ewi.settlement_record_id = sr.id;
    `)
  } else if (status === 'rejected') {
    await executeSql(`
      update settlement_records sr
      set locked_amount = greatest(sr.locked_amount - ewi.amount, 0),
          available_amount = sr.available_amount + ewi.amount,
          status = 'available',
          updated_at = now()
      from expert_withdrawal_items ewi
      where ewi.withdrawal_id = ${sqlLiteral(withdrawalId)}::uuid
        and ewi.settlement_record_id = sr.id;
    `)
  }
  const item = await getExpertWithdrawalById(withdrawalId)
  await auditAdmin(actor, 'expert_withdrawal.update', 'expert_withdrawal', withdrawalId, item)
  return item
}

export async function listAdminNewsArticles(token, filters = {}) {
  await requireAdmin(token, 'news:read')
  const category = String(filters.category || filters.categoryCode || filters.category_code || '').trim()
  return queryJson(`
    select coalesce(json_agg(${newsArticleJson('n')} order by n.published_at desc nulls last, n.created_at desc), '[]'::json)
    from news_articles n
    where (${sqlLiteral(filters.status || '')} = '' or n.status = ${sqlLiteral(filters.status || '')})
      and (${sqlLiteral(category)} = '' or n.category = ${sqlLiteral(category)} or n.category_code = ${sqlLiteral(category)});
  `)
}

export async function createAdminNewsArticle(token, payload) {
  const { actor } = await requireAdmin(token, 'news:write')
  const article = normalizeNewsPayload(payload, true)
  const category = await resolveNewsCategory(article.categoryCode || article.category)
  await ensureNewsFeaturedSlot(article.featuredPosition, null)
  const created = await queryJson(`
    insert into news_articles (slug, title, category_code, category, summary, content, author_name, cover_url, status, featured_position, related_article_ids, published_at)
    values (
      ${sqlLiteral(article.slug)},
      ${sqlLiteral(article.title)},
      ${sqlLiteral(category.code)},
      ${sqlLiteral(category.name)},
      ${sqlLiteral(article.summary)},
      ${sqlLiteral(article.content)},
      ${sqlLiteral(article.authorName)},
      ${sqlLiteral(article.coverUrl)},
      ${sqlLiteral(article.status)},
      ${article.featuredPosition || 'null'},
      ${sqlTextArray(article.relatedArticleIds)},
      ${article.publishedAt ? `${sqlLiteral(article.publishedAt)}::timestamptz` : 'null'}
    )
    returning ${newsArticleJson('news_articles')};
  `)

  await refreshNewsFeaturedSnapshotIfComplete()
  await auditAdmin(actor, 'news.create', 'news_article', created.id, created)
  return created
}

export async function updateAdminNewsArticle(token, articleId, payload) {
  const { actor } = await requireAdmin(token, 'news:write')
  const current = await getAdminNewsArticleById(articleId)
  if (!current) {
    const error = new Error('资讯不存在')
    error.statusCode = 404
    error.code = 'news_not_found'
    throw error
  }

  const next = normalizeNewsPayload({ ...current, ...payload }, false)
  const category = await resolveNewsCategory(next.categoryCode || next.category)
  await ensureNewsFeaturedSlot(next.featuredPosition, current.id)
  const updated = await queryJson(`
    update news_articles
    set
      slug = ${sqlLiteral(next.slug)},
      title = ${sqlLiteral(next.title)},
      category_code = ${sqlLiteral(category.code)},
      category = ${sqlLiteral(category.name)},
      summary = ${sqlLiteral(next.summary)},
      content = ${sqlLiteral(next.content)},
      author_name = ${sqlLiteral(next.authorName)},
      cover_url = ${sqlLiteral(next.coverUrl)},
      status = ${sqlLiteral(next.status)},
      featured_position = ${next.featuredPosition || 'null'},
      related_article_ids = ${sqlTextArray(next.relatedArticleIds)},
      published_at = ${next.publishedAt ? `${sqlLiteral(next.publishedAt)}::timestamptz` : 'null'},
      updated_at = now()
    where id = ${sqlLiteral(current.id)}::uuid
    returning ${newsArticleJson('news_articles')};
  `)

  await refreshNewsFeaturedSnapshotIfComplete()
  await auditAdmin(actor, 'news.update', 'news_article', updated.id, { before: current, after: updated })
  return updated
}

export async function deleteAdminNewsArticle(token, articleId) {
  const { actor } = await requireAdmin(token, 'news:write')
  const current = await getAdminNewsArticleById(articleId)
  if (!current) {
    const error = new Error('资讯不存在')
    error.statusCode = 404
    error.code = 'news_not_found'
    throw error
  }
  await executeSql(`
    delete from news_articles where id = ${sqlLiteral(articleId)}::uuid;
    update news_articles
    set related_article_ids = array_remove(related_article_ids, ${sqlLiteral(articleId)}), updated_at = now()
    where ${sqlLiteral(articleId)} = any(related_article_ids);
  `)
  await refreshNewsFeaturedSnapshotIfComplete()
  await auditAdmin(actor, 'news.delete', 'news_article', current.id, current)
  return { ok: true }
}

export async function setAdminNewsStatus(token, articleId, status) {
  const { actor } = await requireAdmin(token, 'news:write')
  if (!newsStatuses.has(status)) {
    const error = new Error('资讯状态无效')
    error.statusCode = 400
    error.code = 'invalid_news_status'
    throw error
  }

  const updated = await queryJson(`
    update news_articles
    set
      status = ${sqlLiteral(status)},
      published_at = case
        when ${sqlLiteral(status)} = 'published' and published_at is null then now()
        else published_at
      end,
      updated_at = now()
    where id = ${sqlLiteral(articleId)}::uuid
    returning ${newsArticleJson('news_articles')};
  `)

  if (!updated) {
    const error = new Error('资讯不存在')
    error.statusCode = 404
    error.code = 'news_not_found'
    throw error
  }

  await refreshNewsFeaturedSnapshotIfComplete()
  await auditAdmin(actor, `news.${status}`, 'news_article', updated.id, updated)
  return updated
}

export async function listAdminProducts(token, filters = {}) {
  await requireAdmin(token, 'product:read')
  const includeServiceTypes = Boolean(filters.includeServiceTypes || filters.include_service_types)
  const visibleTypes = includeServiceTypes
    ? ''
    : `and p.type in ('membership', 'course', 'agent')`
  return queryJson(`
    select coalesce(json_agg(${productJson('p')} order by p.updated_at desc), '[]'::json)
    from products p
    where (${sqlLiteral(filters.type || '')} = '' or p.type = ${sqlLiteral(filters.type || '')})
      ${visibleTypes}
      and (${sqlLiteral(filters.status || '')} = '' or p.status = ${sqlLiteral(filters.status || '')});
  `)
}

export async function listAdminMembershipPlans(token) {
  await requireAdminAny(token, ['membership_plan:read', 'product:read'])
  return queryJson(`
    select coalesce(json_agg(json_build_object(
      'productId', mp.product_id,
      'slug', p.slug,
      'level', mp.level,
      'name', mp.name,
      'badge', mp.badge,
      'description', mp.description,
      'ctaLabel', mp.cta_label,
      'tone', mp.tone,
      'monthlyPriceAmount', mp.monthly_price_amount,
      'yearlyPriceAmount', mp.yearly_price_amount,
      'recommended', mp.recommended,
      'sortOrder', mp.sort_order,
      'status', mp.status,
      'productStatus', p.status
    ) order by mp.sort_order), '[]'::json)
    from membership_plans mp
    join products p on p.id = mp.product_id;
  `)
}

export async function upsertAdminMembershipPlan(token, payload = {}) {
  await requireAdminAny(token, ['membership_plan:write', 'product:write'])
  const productId = payload.productId || `membership-${payload.level}`
  const level = String(payload.level || '').trim()
  if (!['junior', 'senior'].includes(level)) {
    const error = new Error('会员等级必须是 junior 或 senior')
    error.statusCode = 400
    throw error
  }
  const monthly = toSafeMoney(payload.monthlyPriceAmount)
  const yearly = toSafeMoney(payload.yearlyPriceAmount)
  await executeSql(`
    insert into products (id, slug, type, title, summary, price_amount, purchase_flow, fulfillment_flow, status)
    values (
      ${sqlLiteral(productId)},
      ${sqlLiteral(payload.slug || productId)},
      'membership',
      ${sqlLiteral(payload.name || '')},
      ${sqlLiteral(payload.description || '')},
      ${monthly},
      'member_checkout',
      'entitlement',
      ${sqlLiteral(payload.productStatus || payload.status || 'draft')}
    )
    on conflict (id) do update set
      slug = excluded.slug,
      title = excluded.title,
      summary = excluded.summary,
      price_amount = excluded.price_amount,
      status = excluded.status,
      updated_at = now();

    insert into membership_plans (
      product_id, level, name, badge, description, cta_label, tone,
      monthly_price_amount, yearly_price_amount, recommended, sort_order, status
    )
    values (
      ${sqlLiteral(productId)},
      ${sqlLiteral(level)},
      ${sqlLiteral(payload.name || '')},
      ${sqlLiteral(payload.badge || '')},
      ${sqlLiteral(payload.description || '')},
      ${sqlLiteral(payload.ctaLabel || '')},
      ${sqlLiteral(payload.tone || 'brand')},
      ${monthly},
      ${yearly},
      ${payload.recommended ? 'true' : 'false'},
      ${toSafeInteger(payload.sortOrder, 0)},
      ${sqlLiteral(payload.status || 'draft')}
    )
    on conflict (product_id) do update set
      level = excluded.level,
      name = excluded.name,
      badge = excluded.badge,
      description = excluded.description,
      cta_label = excluded.cta_label,
      tone = excluded.tone,
      monthly_price_amount = excluded.monthly_price_amount,
      yearly_price_amount = excluded.yearly_price_amount,
      recommended = excluded.recommended,
      sort_order = excluded.sort_order,
      status = excluded.status,
      updated_at = now();
  `)
  return (await listAdminMembershipPlans(token)).find((item) => item.productId === productId)
}

export async function listAdminAgentProducts(token) {
  await requireAdmin(token, 'product:read')
  return queryJson(`
    select coalesce(json_agg(agent_product_json(p, d) order by d.sort_order, p.created_at), '[]'::json)
    from products p
    join agent_product_details d on d.product_id = p.id
    where p.type = 'agent';
  `)
}

export async function upsertAdminAgentProduct(token, payload = {}) {
  await requireAdmin(token, 'product:write')
  const productId = payload.productId || payload.id || `agent-${payload.slug}`
  if (!productId || !payload.slug || !payload.title) {
    const error = new Error('智能体必须包含 slug 和标题')
    error.statusCode = 400
    throw error
  }
  const registeredAccess = normalizeCourseAccess(payload.registeredAccess, 'paid')
  const juniorAccess = normalizeCourseAccess(payload.juniorAccess, 'paid')
  const seniorAccess = normalizeCourseAccess(payload.seniorAccess, 'paid')
  const registeredRawPriceAmount = toSafeMoney(payload.registeredPriceAmount ?? payload.priceAmount)
  const juniorRawPriceAmount = toSafeMoney(payload.juniorPriceAmount ?? payload.priceAmount)
  const seniorRawPriceAmount = toSafeMoney(payload.seniorPriceAmount ?? payload.priceAmount)
  assertPaidAgentPrice('注册用户', registeredAccess, registeredRawPriceAmount)
  assertPaidAgentPrice('初级会员', juniorAccess, juniorRawPriceAmount)
  assertPaidAgentPrice('高级会员', seniorAccess, seniorRawPriceAmount)
  const registeredPriceAmount = registeredAccess === 'paid' ? registeredRawPriceAmount : 0
  const juniorPriceAmount = juniorAccess === 'paid' ? juniorRawPriceAmount : 0
  const seniorPriceAmount = seniorAccess === 'paid' ? seniorRawPriceAmount : 0
  const displayPriceAmount = agentMatrixDisplayPrice(
    registeredAccess,
    registeredPriceAmount,
    juniorAccess,
    juniorPriceAmount,
    seniorAccess,
    seniorPriceAmount,
  )
  const sortOrder = productSortOrder(payload.sortOrder, 0)
  await shiftProductSortOrder('agent_product_details', productId, sortOrder)

  await executeSql(`
    insert into products (id, slug, type, title, summary, price_amount, purchase_flow, fulfillment_flow, status)
    values (
      ${sqlLiteral(productId)},
      ${sqlLiteral(payload.slug)},
      'agent',
      ${sqlLiteral(payload.title)},
      ${sqlLiteral(payload.summary || '')},
      ${displayPriceAmount},
      'cart',
      'task',
      ${sqlLiteral(payload.productStatus || payload.status || 'draft')}
    )
    on conflict (id) do update set
      slug = excluded.slug,
      title = excluded.title,
      summary = excluded.summary,
      price_amount = excluded.price_amount,
      status = excluded.status,
      updated_at = now();

    insert into agent_product_details (
      product_id, cover_url, scenario_tags, detail_description, delivery_note,
      registered_access, junior_access, senior_access,
      registered_price_amount, junior_price_amount, senior_price_amount,
      redeemable_by_member, recommended, sort_order, status
    )
    values (
      ${sqlLiteral(productId)},
      ${sqlLiteral(normalizeManagedAssetUrl(payload.coverUrl || '', '智能体封面'))},
      ${sqlLiteral(`{${String(payload.scenarioTags || '').split(/[，,]/).map((item) => item.trim()).filter(Boolean).join(',')}}`)}::text[],
      ${sqlLiteral(payload.detailDescription || '')},
      ${sqlLiteral(payload.deliveryNote || '')},
      ${sqlLiteral(registeredAccess)},
      ${sqlLiteral(juniorAccess)},
      ${sqlLiteral(seniorAccess)},
      ${registeredPriceAmount},
      ${juniorPriceAmount},
      ${seniorPriceAmount},
      ${payload.redeemableByMember ? 'true' : 'false'},
      ${payload.recommended ? 'true' : 'false'},
      ${sortOrder},
      ${sqlLiteral(payload.status || 'draft')}
    )
    on conflict (product_id) do update set
      cover_url = excluded.cover_url,
      scenario_tags = excluded.scenario_tags,
      detail_description = excluded.detail_description,
      delivery_note = excluded.delivery_note,
      registered_access = excluded.registered_access,
      junior_access = excluded.junior_access,
      senior_access = excluded.senior_access,
      registered_price_amount = excluded.registered_price_amount,
      junior_price_amount = excluded.junior_price_amount,
      senior_price_amount = excluded.senior_price_amount,
      redeemable_by_member = excluded.redeemable_by_member,
      recommended = excluded.recommended,
      sort_order = excluded.sort_order,
      status = excluded.status,
      updated_at = now();
  `)
  return (await listAdminAgentProducts(token)).find((item) => item.id === productId)
}

export async function listAdminCourseProducts(token) {
  await requireAdmin(token, 'product:read')
  return queryJson(`
    select coalesce(json_agg(${courseProductJson('p', 'd')} order by d.recommended desc, d.sort_order, p.created_at), '[]'::json)
    from products p
    join course_product_details d on d.product_id = p.id
    where p.type = 'course';
  `)
}

export async function upsertAdminCourseProduct(token, payload = {}) {
  await requireAdmin(token, 'product:write')
  const productId = payload.productId || payload.id || `course-${payload.slug}`
  if (!productId || !payload.slug || !payload.title) {
    const error = new Error('课程商品必须包含 slug 和标题')
    error.statusCode = 400
    throw error
  }
  const courseStatus = ['upcoming', 'ongoing', 'completed', 'anytime'].includes(payload.courseStatus) ? payload.courseStatus : 'anytime'
  const courseFormat = String(payload.courseFormat || '线上课程')
  const checkinRequired = courseFormat === '线下培训' ? true : Boolean(payload.checkinRequired)
  const capacity = courseFormat === '线下培训' ? Math.max(0, toSafeInteger(payload.capacity, 0)) : 0
  const detailStatus = productStatuses.has(payload.status) ? payload.status : 'draft'
  const productStatus = productStatuses.has(payload.productStatus || payload.status) ? (payload.productStatus || payload.status) : detailStatus
  const registeredAccess = normalizeCourseAccess(payload.registeredAccess, 'paid')
  const juniorAccess = normalizeCourseAccess(payload.juniorAccess, 'paid')
  const seniorAccess = normalizeCourseAccess(payload.seniorAccess, 'paid')
  const accessPolicy = legacyAccessPolicyFromMatrix(registeredAccess, juniorAccess, seniorAccess)
  const memberLevelRequired = registeredAccess !== 'closed' ? 0 : juniorAccess !== 'closed' ? 1 : seniorAccess !== 'closed' ? 2 : 2
  const courseType = registeredAccess === 'free' ? 'free' : registeredAccess === 'paid' || juniorAccess === 'paid' || seniorAccess === 'paid' ? 'paid' : memberLevelRequired === 2 ? 'senior' : memberLevelRequired === 1 ? 'junior' : 'free'
  const registeredRawPriceAmount = toSafeMoney(payload.registeredPriceAmount ?? payload.priceAmount)
  const juniorRawPriceAmount = toSafeMoney(payload.juniorPriceAmount ?? payload.priceAmount)
  const seniorRawPriceAmount = toSafeMoney(payload.seniorPriceAmount ?? payload.priceAmount)
  assertPaidCoursePrice('注册用户', registeredAccess, registeredRawPriceAmount)
  assertPaidCoursePrice('初级会员', juniorAccess, juniorRawPriceAmount)
  assertPaidCoursePrice('高级会员', seniorAccess, seniorRawPriceAmount)
  const registeredPriceAmount = registeredAccess === 'paid' ? registeredRawPriceAmount : 0
  const juniorPriceAmount = juniorAccess === 'paid' ? juniorRawPriceAmount : 0
  const seniorPriceAmount = seniorAccess === 'paid' ? seniorRawPriceAmount : 0
  const displayPriceAmount = courseMatrixDisplayPrice(
    registeredAccess,
    registeredPriceAmount,
    juniorAccess,
    juniorPriceAmount,
    seniorAccess,
    seniorPriceAmount,
  )
  const modules = normalizeCourseModules(payload.lessonModules, payload.modules)
  const sortOrder = productSortOrder(payload.sortOrder, 0)
  await shiftProductSortOrder('course_product_details', productId, sortOrder)

  await executeSql(`
    insert into products (id, slug, type, title, summary, price_amount, purchase_flow, fulfillment_flow, status)
    values (
      ${sqlLiteral(productId)},
      ${sqlLiteral(payload.slug)},
      'course',
      ${sqlLiteral(payload.title)},
      ${sqlLiteral(payload.summary || '')},
      ${displayPriceAmount},
      'cart',
      'entitlement',
      ${sqlLiteral(productStatus)}
    )
    on conflict (id) do update set
      slug = excluded.slug,
      title = excluded.title,
      summary = excluded.summary,
      price_amount = excluded.price_amount,
      purchase_flow = excluded.purchase_flow,
      fulfillment_flow = excluded.fulfillment_flow,
      status = excluded.status,
      updated_at = now();

    insert into course_product_details (
      product_id, instructor, course_type, member_level_required, lesson_count,
      starts_at, location, course_status, direction, course_format, cover_label,
      audience, outcomes, modules, access_note, sort_order, status, access_policy,
      registered_access, junior_access, senior_access, registered_price_amount,
      junior_price_amount, senior_price_amount, cover_url, live_url, replay_url,
      capacity, checkin_required, recommended
    )
    values (
      ${sqlLiteral(productId)},
      ${sqlLiteral(payload.instructor || '')},
      ${sqlLiteral(courseType)},
      ${memberLevelRequired},
      ${toSafeInteger(payload.lessonCount, 0)},
      ${payload.startsAt ? `${sqlLiteral(payload.startsAt)}::timestamptz` : 'null'},
      ${sqlLiteral(payload.location || '')},
      ${sqlLiteral(courseStatus)},
      ${sqlLiteral(payload.direction || '')},
      ${sqlLiteral(courseFormat)},
      ${sqlLiteral(payload.coverLabel || '')},
      ${sqlLiteral(payload.audience || '')},
      ${sqlTextArray(payload.outcomes)},
      ${sqlTextArray(modules.map((item) => item.title))},
      ${sqlLiteral(payload.accessNote || '')},
      ${sortOrder},
      ${sqlLiteral(detailStatus)},
      ${sqlLiteral(accessPolicy)},
      ${sqlLiteral(registeredAccess)},
      ${sqlLiteral(juniorAccess)},
      ${sqlLiteral(seniorAccess)},
      ${registeredPriceAmount},
      ${juniorPriceAmount},
      ${seniorPriceAmount},
      ${sqlLiteral(normalizeManagedAssetUrl(payload.coverUrl || '', '课程封面'))},
      ${sqlLiteral(payload.liveUrl || '')},
      ${sqlLiteral(payload.replayUrl || '')},
      ${capacity},
      ${checkinRequired ? 'true' : 'false'},
      ${payload.recommended ? 'true' : 'false'}
    )
    on conflict (product_id) do update set
      instructor = excluded.instructor,
      course_type = excluded.course_type,
      member_level_required = excluded.member_level_required,
      lesson_count = excluded.lesson_count,
      starts_at = excluded.starts_at,
      location = excluded.location,
      course_status = excluded.course_status,
      direction = excluded.direction,
      course_format = excluded.course_format,
      cover_label = excluded.cover_label,
      audience = excluded.audience,
      outcomes = excluded.outcomes,
      modules = excluded.modules,
      access_note = excluded.access_note,
      sort_order = excluded.sort_order,
      status = excluded.status,
      access_policy = excluded.access_policy,
      registered_access = excluded.registered_access,
      junior_access = excluded.junior_access,
      senior_access = excluded.senior_access,
      registered_price_amount = excluded.registered_price_amount,
      junior_price_amount = excluded.junior_price_amount,
      senior_price_amount = excluded.senior_price_amount,
      cover_url = excluded.cover_url,
      live_url = excluded.live_url,
      replay_url = excluded.replay_url,
      capacity = excluded.capacity,
      checkin_required = excluded.checkin_required,
      recommended = excluded.recommended,
      updated_at = now();
  `)

  await executeSql(`delete from course_modules where product_id = ${sqlLiteral(productId)};`)
  for (const [index, module] of modules.entries()) {
    await executeSql(`
      insert into course_modules (
        id, product_id, title, description, video_url, material_url,
        duration_minutes, sort_order, status
      )
      values (
        ${sqlLiteral(module.id || `${productId}-module-${index + 1}`)},
        ${sqlLiteral(productId)},
        ${sqlLiteral(module.title)},
        ${sqlLiteral(module.description || '')},
        ${sqlLiteral(module.videoUrl || '')},
        ${sqlLiteral(module.materialUrl || '')},
        ${Number(module.durationMinutes || 0)},
        ${Number(module.sortOrder || (index + 1) * 10)},
        ${sqlLiteral(module.status || 'published')}
      );
    `)
  }
  return (await listAdminCourseProducts(token)).find((item) => item.id === productId)
}

export async function setAdminCourseProductStatus(token, productId, status) {
  await requireAdmin(token, 'product:write')
  if (!productStatuses.has(status)) {
    const error = new Error('课程状态无效')
    error.statusCode = 400
    throw error
  }
  await executeSql(`
    update products set status = ${sqlLiteral(status)}, updated_at = now()
    where id = ${sqlLiteral(productId)} and type = 'course';
    update course_product_details set status = ${sqlLiteral(status)}, updated_at = now()
    where product_id = ${sqlLiteral(productId)};
  `)
  return (await listAdminCourseProducts(token)).find((item) => item.id === productId)
}

export async function setAdminAgentProductStatus(token, productId, status) {
  await requireAdmin(token, 'product:write')
  if (!productStatuses.has(status)) {
    const error = new Error('智能体状态无效')
    error.statusCode = 400
    error.code = 'invalid_agent_product_status'
    throw error
  }
  await executeSql(`
    update products
    set status = ${sqlLiteral(status)}, updated_at = now()
    where id = ${sqlLiteral(productId)} and type = 'agent';
    update agent_product_details
    set status = ${sqlLiteral(status)}, updated_at = now()
    where product_id = ${sqlLiteral(productId)};
  `)
  return (await listAdminAgentProducts(token)).find((item) => item.id === productId)
}

export async function deleteAdminAgentProduct(token, productId) {
  await requireAdmin(token, 'product:write')
  await executeSql(`
    delete from products
    where id = ${sqlLiteral(productId)}
      and type = 'agent';
  `)
  return { ok: true }
}

export async function deleteAdminCourseProduct(token, productId) {
  await requireAdmin(token, 'product:write')
  await executeSql(`
    update products
    set status = 'offline', updated_at = now()
    where id = ${sqlLiteral(productId)}
      and type = 'course';
    update course_product_details
    set status = 'offline', recommended = false, updated_at = now()
    where product_id = ${sqlLiteral(productId)};
  `)
  return { ok: true }
}

const eventTypes = new Set(['public', 'pro', 'max', 'free'])
const eventTopics = new Set(['峰会论坛', '政策研讨', 'AI应用', '数据安全', '企业参访'])
const eventFormats = new Set(['线下活动', '线上活动', '工作坊', '参访交流'])
const eventDetailStatuses = new Set(['draft', 'published', 'registration_closed', 'offline', 'deleted'])

function normalizeEventProductPayload(payload = {}) {
  const slug = String(payload.slug || '').trim()
  const productId = payload.productId || payload.id || `event-${slug}`
  const title = String(payload.title || '').trim()
  const status = eventDetailStatuses.has(payload.status) ? payload.status : 'draft'
  const productStatusInput = payload.productStatus || (status === 'registration_closed' ? 'published' : status)
  const productStatus = productStatuses.has(productStatusInput) ? productStatusInput : 'draft'
  const registeredAccess = normalizeCourseAccess(payload.registeredAccess, 'paid')
  const juniorAccess = normalizeCourseAccess(payload.juniorAccess, 'paid')
  const seniorAccess = normalizeCourseAccess(payload.seniorAccess, 'paid')
  const registeredPriceAmount = registeredAccess === 'paid' ? toSafeMoney(payload.registeredPriceAmount ?? payload.priceAmount) : 0
  const juniorPriceAmount = juniorAccess === 'paid' ? toSafeMoney(payload.juniorPriceAmount ?? payload.priceAmount) : 0
  const seniorPriceAmount = seniorAccess === 'paid' ? toSafeMoney(payload.seniorPriceAmount ?? payload.priceAmount) : 0

  if (!productId || !slug || !title) {
    const error = new Error('活动必须包含标题和 slug')
    error.statusCode = 400
    error.code = 'invalid_event_product'
    throw error
  }
  assertPaidEventPrice('注册用户', registeredAccess, registeredPriceAmount)
  assertPaidEventPrice('初级会员', juniorAccess, juniorPriceAmount)
  assertPaidEventPrice('高级会员', seniorAccess, seniorPriceAmount)

  return {
    productId,
    slug,
    title,
    summary: String(payload.summary || ''),
    eventType: eventTypes.has(payload.eventType) ? payload.eventType : 'public',
    topic: eventTopics.has(payload.topic) ? payload.topic : '峰会论坛',
    eventFormat: eventFormats.has(payload.eventFormat) ? payload.eventFormat : '线下活动',
    coverUrl: normalizeManagedAssetUrl(payload.coverUrl || '', '活动封面'),
    coverLabel: String(payload.coverLabel || ''),
    audience: String(payload.audience || ''),
    highlights: sqlTextArray(payload.highlights),
    agenda: sqlTextArray(payload.agenda),
    startsAt: payload.startsAt || null,
    endsAt: payload.endsAt || null,
    location: String(payload.location || ''),
    isOnline: Boolean(payload.isOnline),
    onlineUrl: String(payload.onlineUrl || ''),
    capacity: Math.max(0, toSafeInteger(payload.capacity, 0)),
    initialRegisteredCount: Math.max(0, toSafeInteger(payload.initialRegisteredCount ?? payload.registeredCount, 0)),
    registeredAccess,
    juniorAccess,
    seniorAccess,
    registeredPriceAmount,
    juniorPriceAmount,
    seniorPriceAmount,
    memberLevelRequired: registeredAccess !== 'closed' ? 0 : juniorAccess !== 'closed' ? 1 : 2,
    displayPriceAmount: courseMatrixDisplayPrice(
      registeredAccess,
      registeredPriceAmount,
      juniorAccess,
      juniorPriceAmount,
      seniorAccess,
      seniorPriceAmount,
    ),
    recommended: Boolean(payload.recommended),
    sortOrder: productSortOrder(payload.sortOrder, 10),
    status,
    productStatus,
  }
}

export async function listAdminEventProducts(token) {
  await requireAdminAny(token, ['event:read', 'product:read'])
  return queryJson(`
    select coalesce(json_agg(${eventProductJson('p', 'd')} order by d.recommended desc, d.updated_at desc, d.sort_order, p.created_at), '[]'::json)
    from products p
    join event_product_details d on d.product_id = p.id
    where p.type = 'event'
      and d.status <> 'deleted';
  `)
}

async function enforceEventRecommendedLimit() {
  await executeSql(`
    with ranked as (
      select
        product_id,
        row_number() over (order by updated_at desc, product_id) as position
      from event_product_details
      where recommended = true
        and status = 'published'
    )
    update event_product_details details
    set recommended = false,
        updated_at = now()
    from ranked
    where details.product_id = ranked.product_id
      and ranked.position > 3;
  `)
}

export async function upsertAdminEventProduct(token, payload = {}) {
  await requireAdminAny(token, ['event:write', 'product:write'])
  const eventProduct = normalizeEventProductPayload(payload)
  await shiftProductSortOrder('event_product_details', eventProduct.productId, eventProduct.sortOrder)

  await executeSql(`
    insert into products (id, slug, type, title, summary, price_amount, purchase_flow, fulfillment_flow, status)
    values (
      ${sqlLiteral(eventProduct.productId)},
      ${sqlLiteral(eventProduct.slug)},
      'event',
      ${sqlLiteral(eventProduct.title)},
      ${sqlLiteral(eventProduct.summary)},
      ${eventProduct.displayPriceAmount},
      'cart',
      'checkin',
      ${sqlLiteral(eventProduct.productStatus)}
    )
    on conflict (id) do update set
      slug = excluded.slug,
      title = excluded.title,
      summary = excluded.summary,
      price_amount = excluded.price_amount,
      purchase_flow = excluded.purchase_flow,
      fulfillment_flow = excluded.fulfillment_flow,
      status = excluded.status,
      updated_at = now();

    insert into event_product_details (
      product_id, event_type, topic, event_format, cover_url, cover_label,
      audience, highlights, agenda, starts_at, ends_at, location, is_online,
      online_url, capacity, initial_registered_count, registered_access,
      junior_access, senior_access, registered_price_amount, junior_price_amount,
      senior_price_amount, member_level_required, recommended, sort_order, status
    )
    values (
      ${sqlLiteral(eventProduct.productId)},
      ${sqlLiteral(eventProduct.eventType)},
      ${sqlLiteral(eventProduct.topic)},
      ${sqlLiteral(eventProduct.eventFormat)},
      ${sqlLiteral(eventProduct.coverUrl)},
      ${sqlLiteral(eventProduct.coverLabel)},
      ${sqlLiteral(eventProduct.audience)},
      ${eventProduct.highlights},
      ${eventProduct.agenda},
      ${eventProduct.startsAt ? `${sqlLiteral(eventProduct.startsAt)}::timestamptz` : 'null'},
      ${eventProduct.endsAt ? `${sqlLiteral(eventProduct.endsAt)}::timestamptz` : 'null'},
      ${sqlLiteral(eventProduct.location)},
      ${eventProduct.isOnline ? 'true' : 'false'},
      ${sqlLiteral(eventProduct.onlineUrl)},
      ${eventProduct.capacity},
      ${eventProduct.initialRegisteredCount},
      ${sqlLiteral(eventProduct.registeredAccess)},
      ${sqlLiteral(eventProduct.juniorAccess)},
      ${sqlLiteral(eventProduct.seniorAccess)},
      ${eventProduct.registeredPriceAmount},
      ${eventProduct.juniorPriceAmount},
      ${eventProduct.seniorPriceAmount},
      ${eventProduct.memberLevelRequired},
      ${eventProduct.recommended ? 'true' : 'false'},
      ${eventProduct.sortOrder},
      ${sqlLiteral(eventProduct.status)}
    )
    on conflict (product_id) do update set
      event_type = excluded.event_type,
      topic = excluded.topic,
      event_format = excluded.event_format,
      cover_url = excluded.cover_url,
      cover_label = excluded.cover_label,
      audience = excluded.audience,
      highlights = excluded.highlights,
      agenda = excluded.agenda,
      starts_at = excluded.starts_at,
      ends_at = excluded.ends_at,
      location = excluded.location,
      is_online = excluded.is_online,
      online_url = excluded.online_url,
      capacity = excluded.capacity,
      initial_registered_count = excluded.initial_registered_count,
      registered_access = excluded.registered_access,
      junior_access = excluded.junior_access,
      senior_access = excluded.senior_access,
      registered_price_amount = excluded.registered_price_amount,
      junior_price_amount = excluded.junior_price_amount,
      senior_price_amount = excluded.senior_price_amount,
      member_level_required = excluded.member_level_required,
      recommended = excluded.recommended,
      sort_order = excluded.sort_order,
      status = excluded.status,
      updated_at = now();
  `)
  if (eventProduct.recommended && eventProduct.status === 'published') {
    await enforceEventRecommendedLimit()
  }
  return (await listAdminEventProducts(token)).find((item) => item.id === eventProduct.productId)
}

export async function setAdminEventProductStatus(token, productId, status) {
  await requireAdminAny(token, ['event:write', 'product:write'])
  const allowedStatuses = new Set(['draft', 'published', 'registration_closed', 'offline'])
  if (!allowedStatuses.has(status)) {
    const error = new Error('活动状态无效')
    error.statusCode = 400
    error.code = 'invalid_event_product_status'
    throw error
  }
  const productStatus = status === 'registration_closed' ? 'published' : status
  await executeSql(`
    update products
    set status = ${sqlLiteral(productStatus)}, updated_at = now()
    where id = ${sqlLiteral(productId)} and type = 'event';
    update event_product_details
    set status = ${sqlLiteral(status)}, updated_at = now()
    where product_id = ${sqlLiteral(productId)};
  `)
  if (status === 'published') {
    await enforceEventRecommendedLimit()
  }
  return (await listAdminEventProducts(token)).find((item) => item.id === productId)
}

export async function deleteAdminEventProduct(token, productId) {
  await requireAdmin(token, 'product:write')
  await executeSql(`
    update products
    set status = 'offline', updated_at = now()
    where id = ${sqlLiteral(productId)} and type = 'event';
    update event_product_details
    set status = 'deleted', recommended = false, updated_at = now()
    where product_id = ${sqlLiteral(productId)};
  `)
  return { ok: true }
}

const dataCategories = new Set(['研究报告', '表格数据', '政策数据库', '资料包'])
const dataFileFormats = new Set(['PDF', 'Excel', 'CSV', 'ZIP'])

function normalizeDataProductPayload(payload = {}) {
  const slug = String(payload.slug || '').trim()
  const productId = payload.productId || payload.id || `data-${slug}`
  const title = String(payload.title || '').trim()
  const category = dataCategories.has(payload.category) ? payload.category : '研究报告'
  const fileFormat = dataFileFormats.has(payload.fileFormat) ? payload.fileFormat : 'PDF'
  const status = productStatuses.has(payload.status) ? payload.status : 'draft'
  const productStatus = productStatuses.has(payload.productStatus || payload.status) ? (payload.productStatus || payload.status) : status
  const priceAmount = toSafeMoney(payload.priceAmount)
  const featuredPosition = Number(payload.featuredPosition || 0)

  if (!productId || !slug || !title) {
    const error = new Error('数据服务案例必须包含标题和 slug')
    error.statusCode = 400
    error.code = 'invalid_data_product'
    throw error
  }

  return {
    productId,
    slug,
    title,
    summary: String(payload.summary || ''),
    priceAmount,
    category,
    fileFormat,
    coverUrl: normalizeManagedAssetUrl(payload.coverUrl || '', '数据服务案例封面'),
    fileUrl: normalizeManagedAssetUrl(payload.fileUrl || '', '数据服务案例附件'),
    fileName: String(payload.fileName || ''),
    fileSize: toSafeInteger(payload.fileSize, 0),
    tags: Array.isArray(payload.tags)
      ? payload.tags.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 12)
      : String(payload.tags || '').split(/[,\n，、]+/).map((item) => item.trim()).filter(Boolean).slice(0, 12),
    caseGoal: String(payload.caseGoal || payload.case_goal || ''),
    requestOutline: String(payload.requestOutline || payload.request_outline || ''),
    deliveryOutline: String(payload.deliveryOutline || payload.delivery_outline || ''),
    serviceNote: String(payload.serviceNote || payload.service_note || ''),
    featuredPosition: featuredPosition ? 1 : null,
    sortOrder: productSortOrder(payload.sortOrder, 10),
    status,
    productStatus,
  }
}

async function replaceDataFeaturedSlot(position, productId) {
  if (!position) return
  await executeSql(`
    update data_product_details
    set featured_position = null,
        updated_at = now()
    where featured_position is not null
      and status <> 'deleted'
      and product_id <> ${sqlLiteral(productId)};
  `)
}

async function listAdminDataProductsInternal() {
  return queryJson(`
    select coalesce(json_agg(${dataProductJson('p', 'd')} order by d.featured_position nulls last, d.sort_order, p.created_at), '[]'::json)
    from products p
    join data_product_details d on d.product_id = p.id
    where p.type = 'data'
      and d.status <> 'deleted';
  `)
}

export async function listAdminDataProducts(token) {
  await requireAdminAny(token, ['data_service_case:read', 'data_page:read', 'product:read'])
  return listAdminDataProductsInternal()
}

export async function listAdminDataCases(token) {
  return listAdminDataProducts(token)
}

export async function upsertAdminDataProduct(token, payload = {}) {
  await requireAdminAny(token, ['data_service_case:write', 'data_page:write', 'product:write'])
  const dataProduct = normalizeDataProductPayload(payload)
  await replaceDataFeaturedSlot(dataProduct.featuredPosition, dataProduct.productId)
  await shiftProductSortOrder('data_product_details', dataProduct.productId, dataProduct.sortOrder)

  await executeSql(`
    insert into products (id, slug, type, title, summary, price_amount, purchase_flow, fulfillment_flow, status)
    values (
      ${sqlLiteral(dataProduct.productId)},
      ${sqlLiteral(dataProduct.slug)},
      'data',
      ${sqlLiteral(dataProduct.title)},
      ${sqlLiteral(dataProduct.summary)},
      ${dataProduct.priceAmount},
      'application',
      'data_service',
      ${sqlLiteral(dataProduct.productStatus)}
    )
    on conflict (id) do update set
      slug = excluded.slug,
      title = excluded.title,
      summary = excluded.summary,
      price_amount = excluded.price_amount,
      purchase_flow = excluded.purchase_flow,
      fulfillment_flow = excluded.fulfillment_flow,
      status = excluded.status,
      updated_at = now();

    insert into data_product_details (
      product_id, category, file_format, cover_url, file_url, file_name, file_size,
      tags, case_goal, request_outline, delivery_outline, service_note,
      featured_position, sort_order, status
    )
    values (
      ${sqlLiteral(dataProduct.productId)},
      ${sqlLiteral(dataProduct.category)},
      ${sqlLiteral(dataProduct.fileFormat)},
      ${sqlLiteral(dataProduct.coverUrl)},
      ${sqlLiteral(dataProduct.fileUrl)},
      ${sqlLiteral(dataProduct.fileName)},
      ${dataProduct.fileSize},
      ${sqlTextArray(dataProduct.tags)},
      ${sqlLiteral(dataProduct.caseGoal)},
      ${sqlLiteral(dataProduct.requestOutline)},
      ${sqlLiteral(dataProduct.deliveryOutline)},
      ${sqlLiteral(dataProduct.serviceNote)},
      ${dataProduct.featuredPosition || 'null'},
      ${dataProduct.sortOrder},
      ${sqlLiteral(dataProduct.status)}
    )
    on conflict (product_id) do update set
      category = excluded.category,
      file_format = excluded.file_format,
      cover_url = excluded.cover_url,
      file_url = excluded.file_url,
      file_name = excluded.file_name,
      file_size = excluded.file_size,
      tags = excluded.tags,
      case_goal = excluded.case_goal,
      request_outline = excluded.request_outline,
      delivery_outline = excluded.delivery_outline,
      service_note = excluded.service_note,
      featured_position = excluded.featured_position,
      sort_order = excluded.sort_order,
      status = excluded.status,
      updated_at = now();
  `)
  return (await listAdminDataProductsInternal()).find((item) => item.id === dataProduct.productId)
}

export async function upsertAdminDataCase(token, payload = {}) {
  return upsertAdminDataProduct(token, payload)
}

export async function setAdminDataProductStatus(token, productId, status) {
  await requireAdminAny(token, ['data_service_case:write', 'data_page:write', 'product:write'])
  if (!productStatuses.has(status)) {
    const error = new Error('数据服务案例状态无效')
    error.statusCode = 400
    throw error
  }
  await executeSql(`
    update products set status = ${sqlLiteral(status)}, updated_at = now()
    where id = ${sqlLiteral(productId)} and type = 'data';
    update data_product_details set status = ${sqlLiteral(status)}, updated_at = now()
    where product_id = ${sqlLiteral(productId)};
  `)
  return (await listAdminDataProductsInternal()).find((item) => item.id === productId)
}

export async function setAdminDataCaseStatus(token, productId, status) {
  return setAdminDataProductStatus(token, productId, status)
}

export async function deleteAdminDataProduct(token, productId) {
  await requireAdminAny(token, ['data_service_case:write', 'data_page:write', 'product:write'])
  await executeSql(`
    update data_product_details set status = 'deleted', featured_position = null, updated_at = now()
    where product_id = ${sqlLiteral(productId)};
    update products set status = 'offline', updated_at = now()
    where id = ${sqlLiteral(productId)} and type = 'data';
  `)
  return { ok: true }
}

export async function deleteAdminDataCase(token, productId) {
  return deleteAdminDataProduct(token, productId)
}

const publicUploadOwnerTypes = new Set(['product_asset', 'cms_asset', 'site_asset', 'expert_avatar'])

function uploadRecordDefaults(file = {}, fallback = {}, allowedOwnerTypes = []) {
  const requestedOwnerType = normalizeUploadedFileOwnerType(file.ownerType || file.owner_type || '')
  const ownerType = allowedOwnerTypes.includes(requestedOwnerType)
    ? requestedOwnerType
    : normalizeUploadedFileOwnerType(fallback.ownerType)
  const visibility = publicUploadOwnerTypes.has(ownerType)
    ? 'public'
    : 'private'
  return {
    ownerType,
    ownerId: String(file.ownerId || file.owner_id || fallback.ownerId || '').trim(),
    visibility,
  }
}

async function materializeUploadedFile(file = {}) {
  if (!Buffer.isBuffer(file.buffer)) return file
  const stored = await saveBufferToFileStorage(file.buffer, file.fileName, file.mimeType)
  return {
    ...file,
    ...stored,
    buffer: undefined,
    contentBase64: '',
    mimeType: file.mimeType || stored.mimeType || 'application/octet-stream',
  }
}

export async function createAdminUploadedFile(token, file) {
  await requireAdmin(token, 'product:write')
  const storedFile = await materializeUploadedFile(file)
  return createUploadedFileRecord(storedFile, uploadRecordDefaults(storedFile, {
    ownerType: 'product_asset',
    visibility: 'public',
  }, ['product_asset', 'cms_asset', 'site_asset', 'expert_avatar', 'expert_material', 'withdrawal_proof']))
}

export async function createStaffUploadedFile(token, file) {
  await requireAdmin(token, 'data_service:update')
  const storedFile = await materializeUploadedFile(file)
  return createUploadedFileRecord(storedFile, uploadRecordDefaults(storedFile, {
    ownerType: 'data_service_deliverable',
    visibility: 'private',
  }, ['data_service_deliverable', 'data_service_request']))
}

export async function createStaffStoredFile(token, file) {
  await requireAdmin(token, 'data_service:update')
  return createUploadedFileRecord(file, uploadRecordDefaults(file, {
    ownerType: 'data_service_deliverable',
    visibility: 'private',
  }, ['data_service_deliverable', 'data_service_request']))
}

export async function createAdminStoredFile(token, file) {
  await requireAdmin(token, 'product:write')
  return createUploadedFileRecord(file, uploadRecordDefaults(file, {
    ownerType: 'product_asset',
    visibility: 'public',
  }, ['product_asset', 'cms_asset', 'site_asset', 'expert_avatar', 'expert_material', 'withdrawal_proof']))
}

export async function createAccountStoredFile(token, file) {
  const { user } = await requireUser(token)
  return createUploadedFileRecord(file, uploadRecordDefaults(file, {
    ownerType: 'user_upload',
    ownerId: user.id,
    visibility: 'private',
  }, ['user_upload', 'data_service_request', 'expert_material']))
}

export async function getUploadedFile(fileId) {
  return getUploadedFileRecord(fileId)
}

export async function createAdminProduct(token, payload) {
  const { actor } = await requireAdmin(token, 'product:write')
  const product = normalizeProductPayload(payload, true)
  if (['event', 'data', 'consulting', 'expert'].includes(product.type)) {
    const error = new Error('该业务类型不能通过泛用商品库新增，请使用对应的活动、数据服务、咨询或专家后台。')
    error.statusCode = 409
    error.code = 'service_product_requires_dedicated_admin'
    throw error
  }

  const created = await queryJson(`
    insert into products (
      id, slug, type, title, summary, price_amount,
      purchase_flow, fulfillment_flow, status
    )
    values (
      ${sqlLiteral(product.id)},
      ${sqlLiteral(product.slug)},
      ${sqlLiteral(product.type)},
      ${sqlLiteral(product.title)},
      ${sqlLiteral(product.summary)},
      ${product.priceAmount},
      ${sqlLiteral(product.purchaseFlow)},
      ${sqlLiteral(product.fulfillmentFlow)},
      ${sqlLiteral(product.status)}
    )
    returning ${productJson('products')};
  `)

  await auditAdmin(actor, 'product.create', 'product', created.id, created)
  return created
}

export async function updateAdminProduct(token, productId, payload) {
  const { actor } = await requireAdmin(token, 'product:write')
  const current = await getAdminProductById(productId)
  if (!current) {
    const error = new Error('商品不存在')
    error.statusCode = 404
    error.code = 'product_not_found'
    throw error
  }

  const next = normalizeProductPayload({ ...current, ...payload, id: current.id }, false)
  if (['event', 'data', 'consulting', 'expert'].includes(current.type) || ['event', 'data', 'consulting', 'expert'].includes(next.type)) {
    const error = new Error('该业务类型不能通过泛用商品库编辑，请使用对应的活动、数据服务、咨询或专家后台。')
    error.statusCode = 409
    error.code = 'service_product_requires_dedicated_admin'
    throw error
  }
  const updated = await queryJson(`
    update products
    set
      slug = ${sqlLiteral(next.slug)},
      type = ${sqlLiteral(next.type)},
      title = ${sqlLiteral(next.title)},
      summary = ${sqlLiteral(next.summary)},
      price_amount = ${next.priceAmount},
      purchase_flow = ${sqlLiteral(next.purchaseFlow)},
      fulfillment_flow = ${sqlLiteral(next.fulfillmentFlow)},
      status = ${sqlLiteral(next.status)},
      updated_at = now()
    where id = ${sqlLiteral(current.id)}
    returning ${productJson('products')};
  `)

  await auditAdmin(actor, 'product.update', 'product', updated.id, { before: current, after: updated })
  return updated
}

export async function setAdminProductStatus(token, productId, status) {
  const { actor } = await requireAdmin(token, 'product:write')
  if (!productStatuses.has(status)) {
    const error = new Error('商品状态无效')
    error.statusCode = 400
    error.code = 'invalid_product_status'
    throw error
  }
  const current = await getAdminProductById(productId)
  if (!current) {
    const error = new Error('商品不存在')
    error.statusCode = 404
    error.code = 'product_not_found'
    throw error
  }
  if (['event', 'data', 'consulting', 'expert'].includes(current.type)) {
    const error = new Error('该业务类型不能通过泛用商品库上下架，请使用对应的活动、数据服务、咨询或专家后台。')
    error.statusCode = 409
    error.code = 'service_product_requires_dedicated_admin'
    throw error
  }

  const updated = await queryJson(`
    update products
    set status = ${sqlLiteral(status)}, updated_at = now()
    where id = ${sqlLiteral(productId)}
    returning ${productJson('products')};
  `)

  if (!updated) {
    const error = new Error('商品不存在')
    error.statusCode = 404
    error.code = 'product_not_found'
    throw error
  }

  await auditAdmin(actor, `product.${status}`, 'product', updated.id, updated)
  return updated
}

export async function listAdminCoupons(token, filters = {}) {
  await requireAdmin(token, 'coupon:read')
  const search = String(filters.search || '').trim()
  return queryJson(`
    select coalesce(json_agg(${couponJson('c')} order by c.updated_at desc), '[]'::json)
    from coupons c
    where (${sqlLiteral(filters.status || '')} = '' or c.status = ${sqlLiteral(filters.status || '')})
      and (${sqlLiteral(filters.discountType || '')} = '' or c.discount_type = ${sqlLiteral(filters.discountType || '')})
      and (${sqlLiteral(search)} = '' or c.code ilike '%' || ${sqlLiteral(search)} || '%' or c.name ilike '%' || ${sqlLiteral(search)} || '%');
  `)
}

export async function getAdminCoupon(token, couponId) {
  await requireAdmin(token, 'coupon:read')
  const coupon = await getAdminCouponById(couponId)
  if (!coupon) {
    const error = new Error('优惠码不存在')
    error.statusCode = 404
    error.code = 'coupon_not_found'
    throw error
  }
  return coupon
}

export async function createAdminCoupon(token, payload) {
  const { actor } = await requireAdmin(token, 'coupon:write')
  const coupon = normalizeCouponPayload(payload, true)
  const created = await queryJson(`
    insert into coupons (
      code, name, description, discount_type, discount_value, max_discount_amount,
      min_order_amount, product_scope, user_scope, usage_limits, stacking_policy,
      budget_amount, total_quantity, starts_at, ends_at, status
    )
    values (
      ${sqlLiteral(coupon.code)},
      ${sqlLiteral(coupon.name)},
      ${sqlLiteral(coupon.description)},
      ${sqlLiteral(coupon.discountType)},
      ${coupon.discountValue},
      ${coupon.maxDiscountAmount === null ? 'null' : coupon.maxDiscountAmount},
      ${coupon.minOrderAmount},
      ${sqlJsonb(coupon.productScope)},
      ${sqlJsonb(coupon.userScope)},
      ${sqlJsonb(coupon.usageLimits)},
      ${sqlJsonb(coupon.stackingPolicy)},
      ${coupon.budgetAmount === null ? 'null' : coupon.budgetAmount},
      ${coupon.totalQuantity},
      ${sqlLiteral(coupon.startsAt)}::timestamptz,
      ${sqlLiteral(coupon.endsAt)}::timestamptz,
      ${sqlLiteral(coupon.status)}
    )
    returning ${couponJson('coupons')};
  `)

  await auditAdmin(actor, 'coupon.create', 'coupon', created.id, created)
  return created
}

export async function updateAdminCoupon(token, couponId, payload) {
  const { actor } = await requireAdmin(token, 'coupon:write')
  const current = await getAdminCouponById(couponId)
  if (!current) {
    const error = new Error('优惠码不存在')
    error.statusCode = 404
    error.code = 'coupon_not_found'
    throw error
  }

  const next = normalizeCouponPayload({ ...current, ...payload }, false)
  const updated = await queryJson(`
    update coupons
    set
      code = ${sqlLiteral(next.code)},
      name = ${sqlLiteral(next.name)},
      description = ${sqlLiteral(next.description)},
      discount_type = ${sqlLiteral(next.discountType)},
      discount_value = ${next.discountValue},
      max_discount_amount = ${next.maxDiscountAmount === null ? 'null' : next.maxDiscountAmount},
      min_order_amount = ${next.minOrderAmount},
      product_scope = ${sqlJsonb(next.productScope)},
      user_scope = ${sqlJsonb(next.userScope)},
      usage_limits = ${sqlJsonb(next.usageLimits)},
      stacking_policy = ${sqlJsonb(next.stackingPolicy)},
      budget_amount = ${next.budgetAmount === null ? 'null' : next.budgetAmount},
      total_quantity = ${next.totalQuantity},
      starts_at = ${sqlLiteral(next.startsAt)}::timestamptz,
      ends_at = ${sqlLiteral(next.endsAt)}::timestamptz,
      status = ${sqlLiteral(next.status)},
      updated_at = now()
    where id = ${sqlLiteral(current.id)}::uuid
    returning ${couponJson('coupons')};
  `)

  await auditAdmin(actor, 'coupon.update', 'coupon', updated.id, { before: current, after: updated })
  return updated
}

export async function deleteAdminCoupon(token, couponId) {
  const { actor } = await requireAdmin(token, 'coupon:write')
  const coupon = await getAdminCouponById(couponId)
  if (!coupon) {
    const error = new Error('优惠码不存在')
    error.statusCode = 404
    error.code = 'coupon_not_found'
    throw error
  }
  if ((coupon.usedQuantity || 0) > 0) {
    const updated = await updateAdminCoupon(token, couponId, { ...coupon, status: 'paused' })
    await auditAdmin(actor, 'coupon.pause_used_delete_request', 'coupon', updated.id, updated)
    return { ok: true, paused: true, item: updated }
  }
  await executeSql(`delete from coupons where id = ${sqlLiteral(couponId)}::uuid;`)
  await auditAdmin(actor, 'coupon.delete', 'coupon', couponId, coupon)
  return { ok: true, paused: false }
}

export async function listAdminCouponRedemptions(token, couponId) {
  await requireAdmin(token, 'coupon:read')
  return queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', r.id,
      'couponId', r.coupon_id,
      'couponCode', coalesce(nullif(r.coupon_code_snapshot, ''), c.code),
      'couponName', coalesce(nullif(r.coupon_name_snapshot, ''), c.name),
      'userId', r.user_id,
      'userName', u.display_name,
      'userPhone', u.phone,
      'passportNo', u.passport_no,
      'orderId', r.order_id,
      'orderNo', o.order_no,
      'discountAmount', r.discount_amount,
      'discountBaseAmount', r.discount_base_amount,
      'ruleSnapshot', r.rule_snapshot,
      'createdAt', r.created_at
    ) order by r.created_at desc), '[]'::json)
    from coupon_redemptions r
    join coupons c on c.id = r.coupon_id
    join users u on u.id = r.user_id
    join orders o on o.id = r.order_id
    where r.coupon_id = ${sqlLiteral(couponId)}::uuid;
  `)
}

export async function previewAdminCoupon(token, payload = {}) {
  await requireAdmin(token, 'coupon:read')
  const user = await resolveAdminCouponPreviewUser(payload)
  const items = await normalizeCheckoutItems(payload.items || [], user)
  const draftCoupon = payload.coupon && typeof payload.coupon === 'object'
    ? normalizeCouponPayload(payload.coupon, false)
    : null
  return buildCheckoutPreview(
    user,
    items,
    payload.couponCode || payload.code || draftCoupon?.code,
    payload.couponId || payload.coupon_id,
    draftCoupon
      ? {
        ...draftCoupon,
        id: payload.couponId || payload.coupon_id || 'preview',
        usedQuantity: 0,
        remainingQuantity: draftCoupon.totalQuantity,
        usedDiscountAmount: 0,
      }
      : null,
  )
}

export async function listAdminSettlements(token, filters = {}) {
  await requireAdmin(token, 'settlement:read')
  return queryJson(`
    select coalesce(json_agg(settlement_json order by (settlement_json->>'createdAt') desc), '[]'::json)
    from (
      select json_build_object(
        'id', sr.id,
        'settlementNo', sr.settlement_no,
        'orderId', sr.order_id,
        'orderNo', o.order_no,
        'userId', sr.user_id,
        'userPhone', u.phone,
        'productId', sr.product_id,
        'productTitle', p.title,
        'beneficiaryType', sr.beneficiary_type,
        'beneficiaryRef', sr.beneficiary_ref,
        'expertId', sr.expert_id,
        'expertName', coalesce(e.name, ''),
        'beneficiaryUserId', sr.beneficiary_user_id,
        'passportNo', sr.passport_no,
        'grossAmount', sr.gross_amount,
        'expertShareAmount', sr.expert_share_amount,
        'platformShareAmount', sr.platform_share_amount,
        'availableAmount', sr.available_amount,
        'lockedAmount', sr.locked_amount,
        'withdrawnAmount', sr.withdrawn_amount,
        'availableAt', sr.available_at,
        'status', sr.status,
        'policySnapshot', sr.policy_snapshot,
        'createdAt', sr.created_at,
        'updatedAt', sr.updated_at
      ) as settlement_json
      from settlement_records sr
      join orders o on o.id = sr.order_id
      join users u on u.id = sr.user_id
      join products p on p.id = sr.product_id
      left join expert_profiles e on e.id = sr.expert_id
      where (${sqlLiteral(filters.status || '')} = '' or sr.status = ${sqlLiteral(filters.status || '')})
    ) t;
  `)
}

export async function listAdminOrders(token, filters = {}) {
  await requireAdmin(token, 'order:read')
  return listAdminOrdersRecord(filters)
}

export async function getAdminOrder(token, orderId) {
  await requireAdmin(token, 'order:read')
  return findAdminOrderRecord(orderId)
}

function httpError(message, statusCode = 400, code = 'bad_request') {
  const error = new Error(message)
  error.statusCode = statusCode
  error.code = code
  return error
}

function orderKeyWhere(key, alias = 'o') {
  const value = String(key || '').trim()
  if (isUuid(value)) return `${alias}.id = ${sqlLiteral(value)}::uuid`
  return `(
    ${alias}.order_no = ${sqlLiteral(value)}
    or ${alias}.legacy_order_no = ${sqlLiteral(value)}
    or ${alias}.settlement_no = ${sqlLiteral(value)}
  )`
}

export async function recordOperationalEvent(payload = {}) {
  return createOperationalEvent(payload)
}

export async function listAdminOperationalEvents(token, filters = {}) {
  await requireAdmin(token, 'operations:read')
  return listOperationalEvents(filters)
}

export async function getAdminOperationalEvent(token, eventId) {
  await requireAdmin(token, 'operations:read')
  return findOperationalEventByKey(eventId)
}

export async function updateAdminOperationalEvent(token, eventId, payload = {}) {
  const { actor, account } = await requireAdmin(token, 'operations:write')
  const status = String(payload.status || '').trim()
  if (!operationalEventStatuses.has(status)) throw httpError('异常状态不合法', 400, 'invalid_operational_event_status')
  const note = String(payload.note || payload.handlerNote || payload.handler_note || '').trim().slice(0, 1000)
  if (['resolved', 'ignored'].includes(status) && !note) throw httpError('解决或忽略异常必须填写处理备注', 400, 'operational_event_note_required')
  const current = await getAdminOperationalEvent(token, eventId)
  if (!current) throw httpError('异常事件不存在', 404, 'operational_event_not_found')
  const result = await updateOperationalEventById(current.id, {
    status,
    handlerNote: note,
    handledByAccountId: isUuid(account?.id) ? account.id : undefined,
  })
  await auditAdmin(actor, 'operational_event.update', 'operational_event', result.id, { before: current, after: result })
  return result
}

export async function retryAdminOperationalEvent(token, eventId, payload = {}) {
  const { actor } = await requireAdmin(token, 'operations:write')
  const current = await getAdminOperationalEvent(token, eventId)
  if (!current) throw httpError('异常事件不存在', 404, 'operational_event_not_found')
  const retry = await recordOperationalEvent({
    severity: 'info',
    eventType: `${current.eventType}.retry_requested`.slice(0, 120),
    resourceType: current.resourceType,
    resourceId: current.resourceId,
    message: `已请求重试：${current.message}`,
    payload: {
      sourceEventId: current.id,
      requestedBy: actor,
      note: String(payload.note || '').trim(),
    },
  })
  await auditAdmin(actor, 'operational_event.retry', 'operational_event', current.id, { event: current, retry })
  return { event: await updateAdminOperationalEvent(token, eventId, { status: 'acknowledged', note: '已发起重试请求' }), retry }
}

export async function resolveAdminOperationalEvent(token, eventId, payload = {}) {
  return updateAdminOperationalEvent(token, eventId, {
    status: payload.status === 'ignored' ? 'ignored' : 'resolved',
    note: payload.note || payload.handlerNote || '已处理',
  })
}

export async function handlePrometheusAlertWebhook(token, payload = {}) {
  const expected = process.env.ALERTMANAGER_TOKEN || ''
  if (process.env.NODE_ENV === 'production' && (!expected || token !== expected)) {
    throw httpError('告警 webhook 令牌无效', 401, 'alert_webhook_unauthorized')
  }
  if (expected && token && token !== expected) throw httpError('告警 webhook 令牌无效', 401, 'alert_webhook_unauthorized')
  const alerts = Array.isArray(payload.alerts) ? payload.alerts : []
  const results = []
  for (const alert of alerts) {
    const labels = alert.labels || {}
    const annotations = alert.annotations || {}
    const eventType = String(labels.event_type || labels.alertname || 'prometheus_alert')
    const fingerprint = String(alert.fingerprint || `${eventType}:${alert.startsAt || Date.now()}`)
    const status = alert.status === 'resolved' ? 'resolved' : 'open'
    const existing = await findOperationalEventByFingerprint(eventType, fingerprint)
    if (existing && status === 'resolved') {
      await updateOperationalEventById(existing.id, {
        status: 'resolved',
        handlerNote: 'Alertmanager resolved',
      })
      results.push({ id: existing.id, status: 'resolved' })
      continue
    }
    if (existing) {
      results.push({ id: existing.id, status: 'existing' })
      continue
    }
    const created = await recordOperationalEvent({
      severity: labels.severity || 'warning',
      eventType,
      resourceType: 'prometheus_alert',
      resourceId: fingerprint,
      message: annotations.summary || annotations.description || eventType,
      payload: alert,
    })
    results.push({ id: created.id, status: 'created' })
  }
  return { accepted: true, count: results.length, items: results }
}

function normalizeInvoicePayload(payload = {}) {
  const title = String(payload.title || payload.invoiceTitle || '').trim()
  const taxNumber = String(payload.taxNumber ?? payload.tax_number ?? '').trim().toUpperCase()
  const email = String(payload.email || payload.invoiceEmail || '').trim().toLowerCase()
  const remark = String(payload.remark || '').trim()
  if (!title) throw httpError('请填写发票抬头', 400, 'invoice_title_required')
  if (title.length > 160) throw httpError('发票抬头不能超过160个字符', 400, 'invoice_title_too_long')
  if (taxNumber.length > 32) throw httpError('税号不能超过32个字符', 400, 'invoice_tax_number_too_long')
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw httpError('请填写有效的接收邮箱', 400, 'invoice_email_invalid')
  if (remark.length > 500) throw httpError('备注不能超过500个字符', 400, 'invoice_remark_too_long')
  return { title, taxNumber, email, remark }
}

function normalizeOptionalInvoiceRequest(payload = {}) {
  const source = payload.invoiceRequest || payload.invoice || payload.invoiceApplication || payload.invoice_application || null
  if (!source || source.requested === false || source.needInvoice === false) return null
  return {
    requested: true,
    source: 'checkout',
    ...normalizeInvoicePayload(source),
  }
}

async function getInvoiceApplicationByKey(key, { userId = '', admin = false } = {}) {
  return findInvoiceApplicationByKey(key, { userId: admin ? '' : userId })
}

export async function createAccountInvoiceApplication(token, orderNo, payload = {}) {
  const { user } = await requireUser(token)
  const invoice = normalizeInvoicePayload(payload)
  const order = await findUserOrderForInvoice(user.id, orderNo)
  if (!order) throw httpError('订单不存在', 404, 'order_not_found')
  if (!['paid', 'completed'].includes(order.status)) {
    throw httpError('订单支付完成后才能申请发票', 400, 'invoice_order_not_paid')
  }
  const amount = Number(order.amount || order.paidAmount || order.totalAmount || 0)
  if (amount <= 0) throw httpError('0 元订单不能申请发票', 400, 'invoice_zero_amount')

  const invoiceNo = await nextBusinessNo('INV')
  const created = await createInvoiceApplication({
    invoiceNo,
    userId: user.id,
    order: { ...order, amount },
    invoice,
  })
  if (created.duplicate) {
    throw httpError('该订单已有发票申请，请勿重复提交', 409, 'invoice_application_exists')
  }
  return created.item
}

export async function listAccountInvoiceApplications(token, filters = {}) {
  const { user } = await requireUser(token)
  return listUserInvoiceApplications(user.id, filters)
}

export async function getAccountInvoiceApplication(token, invoiceNo) {
  const { user } = await requireUser(token)
  return getInvoiceApplicationByKey(invoiceNo, { userId: user.id })
}

export async function listAdminInvoiceApplications(token, filters = {}) {
  await requireAdmin(token, 'invoice:read')
  return listInvoiceApplications(filters)
}

export async function listAdminInvoiceApplicationRows(token, filters = {}) {
  await requireAdmin(token, 'invoice:read')
  return listInvoiceApplicationRows(filters)
}

export async function getAdminInvoiceApplication(token, invoiceNo) {
  await requireAdmin(token, 'invoice:read')
  return getInvoiceApplicationByKey(invoiceNo, { admin: true })
}

function normalizeInvoiceStatusPayload(current, payload = {}) {
  const status = String(payload.status || current.status || '').trim()
  if (!invoiceStatuses.has(status)) throw httpError('发票状态不合法', 400, 'invalid_invoice_status')
  const providerInvoiceId = String(payload.providerInvoiceId ?? payload.provider_invoice_id ?? current.providerInvoiceId ?? '').trim()
  const failureReason = String(payload.failureReason ?? payload.failure_reason ?? current.failureReason ?? '').trim()
  const adminNote = String(payload.adminNote ?? payload.admin_note ?? '').trim()
  const issuedBy = String(payload.issuedBy ?? payload.issued_by ?? current.issuedBy ?? '').trim()
  const issuedAt = String(payload.issuedAt ?? payload.issued_at ?? current.issuedAt ?? '').trim()
  const allowedTransitions = {
    pending: new Set(['pending', 'processing', 'rejected']),
    processing: new Set(['processing', 'issued', 'failed']),
    failed: new Set(['failed', 'processing']),
    rejected: new Set(['rejected']),
    issued: new Set(['issued']),
  }
  if (!allowedTransitions[current.status]?.has(status)) {
    throw httpError('当前发票状态不允许这样流转', 400, 'invalid_invoice_transition')
  }
  if (status === 'issued') {
    if (!providerInvoiceId) throw httpError('已开票必须填写真实发票号码', 400, 'invoice_number_required')
    if (!issuedAt) throw httpError('已开票必须填写开票时间', 400, 'invoice_issued_at_required')
    if (!current.pdfFileId && !payload.pdfFileId && !payload.pdf_file_id) {
      throw httpError('已开票必须先上传发票 PDF/OFD 文件', 400, 'invoice_file_required')
    }
  }
  if ((status === 'rejected' || status === 'failed') && !failureReason && !adminNote) {
    throw httpError('驳回或失败时请填写原因', 400, 'invoice_reason_required')
  }
  return { status, providerInvoiceId, failureReason, adminNote, issuedBy, issuedAt }
}

export async function updateAdminInvoiceApplication(token, invoiceNo, payload = {}) {
  const { actor } = await requireAdmin(token, 'invoice:write')
  const current = await getInvoiceApplicationByKey(invoiceNo, { admin: true })
  if (!current) throw httpError('发票申请不存在', 404, 'invoice_not_found')
  const next = normalizeInvoiceStatusPayload(current, payload)
  const result = await updateInvoiceApplicationRecord(current, next)
  await auditAdmin(actor, 'invoice.update', 'invoice_application', result.invoiceNo, { before: current, after: result })
  return result
}

export async function attachAdminInvoiceFile(token, invoiceNo, file) {
  const { actor } = await requireAdmin(token, 'invoice:write')
  const current = await getInvoiceApplicationByKey(invoiceNo, { admin: true })
  if (!current) throw httpError('发票申请不存在', 404, 'invoice_not_found')
  const fileName = String(file.fileName || 'invoice.pdf').trim()
  const mimeType = String(file.mimeType || 'application/octet-stream').trim()
  if (!/\.(pdf|ofd)$/i.test(fileName) && !['application/pdf', 'application/ofd', 'application/octet-stream'].includes(mimeType)) {
    throw httpError('发票文件仅支持 PDF 或 OFD', 400, 'invalid_invoice_file_type')
  }
  const { invoice: result, file: uploaded } = await attachInvoiceFileRecord(current, {
    ...file,
    fileName,
    mimeType,
  })
  await auditAdmin(actor, 'invoice.file_upload', 'invoice_application', result.invoiceNo, { invoice: result, file: uploaded })
  return { invoice: result, file: uploaded }
}

export async function listAdminPayments(token, filters = {}) {
  await requireAdmin(token, 'payment:read')
  return listAdminPaymentRecords(filters)
}

const adminFinanceStatuses = new Set(['pending_payment', 'paid', 'closed'])

function adminFinanceWhere(filters = {}) {
  const search = String(filters.search || '').trim()
  const from = String(filters.from || '').trim()
  const to = String(filters.to || '').trim()
  const type = productTypes.has(filters.type) ? filters.type : ''
  const requestedStatus = String(filters.status || '').trim()
  const status = adminFinanceStatuses.has(requestedStatus) ? requestedStatus : 'paid'

  return `
    o.status = ${sqlLiteral(status)}
    and (${sqlLiteral(from)} = '' or o.created_at >= nullif(${sqlLiteral(from)}, '')::date)
    and (${sqlLiteral(to)} = '' or o.created_at < (nullif(${sqlLiteral(to)}, '')::date + interval '1 day'))
    and (${sqlLiteral(type)} = '' or exists (
      select 1
      from order_items type_oi
      where type_oi.order_id = o.id
        and type_oi.product_type = ${sqlLiteral(type)}
    ))
    and (${sqlLiteral(search)} = ''
      or o.order_no ilike '%' || ${sqlLiteral(search)} || '%'
      or o.legacy_order_no ilike '%' || ${sqlLiteral(search)} || '%'
      or o.settlement_no ilike '%' || ${sqlLiteral(search)} || '%'
      or u.phone ilike '%' || ${sqlLiteral(search)} || '%'
      or exists (
        select 1
        from payment_orders search_po
        where search_po.order_id = o.id
          and (search_po.payment_no ilike '%' || ${sqlLiteral(search)} || '%'
            or search_po.legacy_payment_no ilike '%' || ${sqlLiteral(search)} || '%')
      )
      or exists (
        select 1
        from order_items search_oi
        where search_oi.order_id = o.id
          and search_oi.title_snapshot ilike '%' || ${sqlLiteral(search)} || '%'
      ))
  `
}

function adminFinanceRowsSql(filters = {}) {
  return `
    select
      o.id,
      o.order_no,
      o.settlement_no,
      o.status as order_status,
      o.subtotal_amount,
      o.member_discount_amount,
      o.coupon_discount_amount,
      o.total_amount,
      o.paid_amount,
      o.created_at,
      o.updated_at,
      u.phone as user_phone,
      coalesce(pay.payment_no, '') as payment_no,
      coalesce(pay.channel, '') as payment_channel,
      coalesce(pay.status, '') as payment_status,
      pay.created_at as payment_created_at,
      pay.updated_at as payment_updated_at,
      coalesce(items.item_summary, '') as item_summary,
      coalesce(items.product_type, '') as product_type,
      coalesce(settlement.expert_share_amount, 0) as expert_share_amount,
      coalesce(settlement.platform_share_amount, 0) as expert_platform_share_amount,
      case
        when coalesce(settlement.gross_amount, 0) > 0 then coalesce(settlement.platform_share_amount, 0)
        else o.paid_amount
      end as platform_keep_amount
    from orders o
    join users u on u.id = o.user_id
    left join lateral (
      select
        po.payment_no,
        po.channel,
        po.status,
        po.created_at,
        po.updated_at
      from payment_orders po
      where po.order_id = o.id
      order by case when po.status = 'succeeded' then 0 else 1 end, po.updated_at desc
      limit 1
    ) pay on true
    left join lateral (
      select
        string_agg(oi.title_snapshot, ' / ' order by oi.title_snapshot) as item_summary,
        string_agg(distinct oi.product_type, ' / ') as product_type
      from order_items oi
      where oi.order_id = o.id
    ) items on true
    left join lateral (
      select
        sum(sr.gross_amount)::int as gross_amount,
        sum(sr.expert_share_amount)::int as expert_share_amount,
        sum(sr.platform_share_amount)::int as platform_share_amount
      from settlement_records sr
      where sr.order_id = o.id
    ) settlement on true
    where ${adminFinanceWhere(filters)}
  `
}

function adminFinanceRowJson(alias = 'r') {
  return `json_build_object(
    'id', ${alias}.id,
    'date', to_char(${alias}.created_at, 'YYYY-MM-DD'),
    'orderNo', ${alias}.order_no,
    'paymentNo', nullif(${alias}.payment_no, ''),
    'settlementNo', ${alias}.settlement_no,
    'userPhone', ${alias}.user_phone,
    'itemSummary', ${alias}.item_summary,
    'productType', ${alias}.product_type,
    'paymentChannel', nullif(${alias}.payment_channel, ''),
    'paymentStatus', nullif(${alias}.payment_status, ''),
    'orderStatus', ${alias}.order_status,
    'subtotalAmount', ${alias}.subtotal_amount,
    'memberDiscountAmount', ${alias}.member_discount_amount,
    'couponDiscountAmount', ${alias}.coupon_discount_amount,
    'totalAmount', ${alias}.total_amount,
    'paidAmount', ${alias}.paid_amount,
    'expertShareAmount', ${alias}.expert_share_amount,
    'expertPlatformShareAmount', ${alias}.expert_platform_share_amount,
    'platformKeepAmount', ${alias}.platform_keep_amount,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at,
    'paymentCreatedAt', ${alias}.payment_created_at,
    'paymentUpdatedAt', ${alias}.payment_updated_at
  )`
}

export async function getAdminRevenueReport(token, filters = {}) {
  await requireAdmin(token, 'settlement:read')
  return queryJson(`
    with rows as (
      ${adminFinanceRowsSql(filters)}
    ),
    summary as (
      select
        coalesce(sum(total_amount), 0)::int as deal_amount,
        coalesce(sum(paid_amount), 0)::int as paid_amount,
        coalesce(sum(platform_keep_amount), 0)::int as platform_income_amount,
        coalesce(sum(case when expert_share_amount > 0 then 0 else paid_amount end), 0)::int as non_expert_income_amount,
        coalesce(sum(expert_platform_share_amount), 0)::int as expert_platform_share_amount,
        coalesce(sum(expert_share_amount), 0)::int as expert_payable_amount,
        count(*)::int as order_count,
        count(*) filter (where payment_no <> '')::int as payment_count
      from rows
    )
    select json_build_object(
      'summary', json_build_object(
        'dealAmount', summary.deal_amount,
        'paidAmount', summary.paid_amount,
        'platformIncomeAmount', summary.platform_income_amount,
        'nonExpertIncomeAmount', summary.non_expert_income_amount,
        'expertPlatformShareAmount', summary.expert_platform_share_amount,
        'expertPayableAmount', summary.expert_payable_amount,
        'orderCount', summary.order_count,
        'paymentCount', summary.payment_count
      ),
      'items', coalesce((
        select json_agg(${adminFinanceRowJson('r')} order by r.created_at desc)
        from rows r
      ), '[]'::json)
    )
    from summary;
  `)
}

export async function listAdminReconcileRows(token, filters = {}) {
  await requireAdmin(token, 'reconcile:export')
  return queryJson(`
    with rows as (
      ${adminFinanceRowsSql(filters)}
    )
    select coalesce(json_agg(${adminFinanceRowJson('r')} order by r.created_at desc), '[]'::json)
    from rows r;
  `)
}

function memberDiscountRate(level) {
  if (level === 'senior') return 0.2
  if (level === 'junior') return 0.1
  return 0
}

function membershipRank(level) {
  if (level === 'senior') return 2
  if (level === 'junior') return 1
  return 0
}

function membershipTierFromProductId(productId = '') {
  return String(productId).includes('senior') ? 'senior' : 'junior'
}

function membershipLabel(tier = '') {
  if (tier === 'senior') return '高级会员'
  if (tier === 'junior') return '初级会员'
  return '注册用户'
}

function normalizeMembershipBillingCycle(value = 'monthly') {
  const cycle = String(value || 'monthly').trim()
  return membershipBillingCycles.has(cycle) ? cycle : 'monthly'
}

function membershipReminderScene(daysUntilExpiry) {
  if (daysUntilExpiry === 7) return 'membership_expiring_d7'
  if (daysUntilExpiry === 3) return 'membership_expiring_d3'
  if (daysUntilExpiry === 1) return 'membership_expiring_d1'
  if (daysUntilExpiry === 0) return 'membership_expiring_today'
  return ''
}

function membershipReminderTitle(daysUntilExpiry, tier) {
  if (daysUntilExpiry === 0) return `${membershipLabel(tier)}今天到期`
  return `${membershipLabel(tier)}将在 ${daysUntilExpiry} 天后到期`
}

function membershipReminderBody(daysUntilExpiry) {
  if (daysUntilExpiry === 0) return '今天未续费将停止会员折扣、专属活动和优先预约等权益。'
  if (daysUntilExpiry === 1) return '建议今天完成续费，避免明天权益中断。'
  return '续费后会员权益会连续保留，不影响后续课程、活动和专家预约使用。'
}

function normalizeNotificationChannels(value = []) {
  const raw = Array.isArray(value) ? value : String(value || '').split(',')
  const channels = raw.map((item) => String(item || '').trim()).filter((item) => notificationChannels.has(item))
  return channels.length ? [...new Set(channels)] : ['in_app']
}

function normalizeNotificationPriority(value = 'normal') {
  const priority = String(value || '').trim()
  return notificationPriorities.has(priority) ? priority : 'normal'
}

function renderNotificationText(template = '', variables = {}) {
  return String(template || '').replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, key) => {
    const value = key.split('.').reduce((current, part) => current && current[part] !== undefined ? current[part] : undefined, variables)
    return value === null || value === undefined || value === '' ? _match : String(value)
  }).trim()
}

async function getUserNotificationPreference(userId) {
  if (!isUuid(userId)) return null
  const preference = await queryJson(`
    select json_build_object(
      'inAppEnabled', in_app_enabled,
      'smsEnabled', sms_enabled,
      'wechatSubscribeEnabled', wechat_subscribe_enabled,
      'emailEnabled', email_enabled
    )
    from notification_preferences
    where user_id = ${sqlLiteral(userId)}::uuid
    limit 1;
  `)
  return preference || {
    inAppEnabled: true,
    smsEnabled: false,
    wechatSubscribeEnabled: false,
    emailEnabled: false,
  }
}

function notificationChannelAllowed(preference, channel) {
  if (!preference) return channel === 'in_app'
  if (channel === 'in_app') return preference.inAppEnabled !== false
  if (channel === 'sms') return preference.smsEnabled === true
  if (channel === 'wechat_subscribe') return preference.wechatSubscribeEnabled === true
  if (channel === 'email') return preference.emailEnabled === true
  return false
}

async function getNotificationTemplate(templateKey) {
  const key = String(templateKey || '').trim()
  if (!key) return null
  return queryJson(`
    select json_build_object(
      'templateKey', template_key,
      'scene', scene,
      'category', category,
      'titleTemplate', title_template,
      'bodyTemplate', body_template,
      'actionUrlTemplate', action_url_template,
      'defaultChannels', default_channels,
      'priority', priority,
      'variablesSchema', variables_schema,
      'status', status,
      'sortOrder', sort_order,
      'createdAt', created_at,
      'updatedAt', updated_at
    )
    from notification_templates
    where template_key = ${sqlLiteral(key)}
    limit 1;
  `)
}

async function renderNotificationPayload(templateKey, payload = {}) {
  const template = await getNotificationTemplate(templateKey)
  const variables = { ...(payload.variables || {}), ...payload }
  if (!template || template.status === 'disabled') {
    return {
      scene: payload.scene || '',
      title: String(payload.title || '').trim(),
      body: String(payload.body || '').trim(),
      actionUrl: String(payload.actionUrl || '').trim(),
      category: payload.category || '',
      channels: normalizeNotificationChannels(payload.channels || []),
      priority: normalizeNotificationPriority(payload.priority || 'normal'),
      template,
    }
  }
  const renderedTitle = renderNotificationText(template.titleTemplate, variables)
  const renderedBody = renderNotificationText(template.bodyTemplate, variables)
  const renderedActionUrl = renderNotificationText(template.actionUrlTemplate, variables)
  return {
    scene: template.scene,
    title: String(payload.title || renderedTitle || '').trim(),
    body: String(payload.body || renderedBody || '').trim(),
    actionUrl: String(payload.actionUrl || renderedActionUrl || '').trim(),
    category: template.category,
    channels: normalizeNotificationChannels(payload.channels || template.defaultChannels),
    priority: normalizeNotificationPriority(payload.priority || template.priority),
    template,
  }
}

async function queueNotificationMessage({
  userId,
  scene,
  templateKey,
  payload = {},
  channel = 'in_app',
  dedupeKey,
  scheduledAt = 'now()',
  status,
  batchId = null,
  priority = '',
  failedReason = '',
  respectPreferences = true,
}) {
  const rendered = await renderNotificationPayload(templateKey, { ...payload, scene, channels: [channel], priority })
  const finalPriority = normalizeNotificationPriority(priority || rendered.priority)
  const messagePayload = {
    ...payload,
    title: rendered.title || payload.title || '',
    body: rendered.body || payload.body || '',
    actionUrl: rendered.actionUrl || payload.actionUrl || '',
    category: rendered.category || payload.category || '',
  }
  const preference = respectPreferences && finalPriority !== 'urgent'
    ? await getUserNotificationPreference(userId)
    : null
  const preferenceBlocked = respectPreferences && finalPriority !== 'urgent' && !notificationChannelAllowed(preference, channel)
  const scheduledImmediately = !scheduledAt || scheduledAt === 'now()'
  const unsupportedExternal = channel !== 'in_app' && scheduledImmediately
  const finalStatus = status || (
    preferenceBlocked || unsupportedExternal
      ? 'skipped'
      : channel === 'in_app' && scheduledImmediately ? 'sent' : 'queued'
  )
  const sentAtSql = finalStatus === 'sent' ? 'now()' : 'null'
  const finalFailedReason = failedReason || (preferenceBlocked
    ? '用户未授权该通知通道'
    : unsupportedExternal ? '外部通知通道尚未接入' : '')
  await executeSql(`
    insert into notification_messages (
      user_id, scene, channel, template_key, payload_json, dedupe_key,
      status, scheduled_at, sent_at, failed_reason, batch_id, priority
    )
    values (
      ${sqlLiteral(userId)}::uuid,
      ${sqlLiteral(rendered.scene || scene)},
      ${sqlLiteral(channel)},
      ${sqlLiteral(templateKey)},
      ${sqlLiteral(JSON.stringify(messagePayload))}::jsonb,
      ${sqlLiteral(dedupeKey)},
      ${sqlLiteral(finalStatus)},
      ${scheduledAt === 'now()' ? 'now()' : sqlLiteral(scheduledAt)},
      ${sentAtSql},
      ${sqlLiteral(finalFailedReason)},
      ${batchId && isUuid(batchId) ? `${sqlLiteral(batchId)}::uuid` : 'null'},
      ${sqlLiteral(finalPriority)}
    )
    on conflict (dedupe_key) do nothing;
  `)
  return {
    status: finalStatus,
    channel,
    skipped: finalStatus === 'skipped',
    sent: finalStatus === 'sent',
  }
}

async function queueServiceNotice(userId, scene, templateKey, payload = {}, dedupeKey = '') {
  if (!isUuid(userId)) return
  await queueNotificationMessage({
    userId,
    scene,
    templateKey,
    payload,
    dedupeKey: dedupeKey || `${scene}:${templateKey}:${userId}:${Date.now()}`,
  })
}

export async function notifyByTemplate(userId, templateKey, variables = {}, options = {}) {
  if (!isUuid(userId)) return null
  const rendered = await renderNotificationPayload(templateKey, variables)
  const channels = normalizeNotificationChannels(options.channels || rendered.channels)
  for (const channel of channels) {
    await queueNotificationMessage({
      userId,
      scene: options.scene || rendered.scene || variables.scene || 'platform',
      templateKey,
      payload: { ...variables, title: rendered.title, body: rendered.body, actionUrl: rendered.actionUrl, category: rendered.category },
      channel,
      dedupeKey: options.dedupeKey || `${rendered.scene || 'platform'}:${templateKey}:${userId}:${channel}:${Date.now()}`,
      scheduledAt: options.scheduledAt || 'now()',
      batchId: options.batchId || null,
      priority: options.priority || rendered.priority,
    })
  }
  return { channels, title: rendered.title, body: rendered.body, actionUrl: rendered.actionUrl }
}

async function writeMembershipEvent({
  subscriptionId = null,
  userId,
  eventType,
  fromTier = null,
  toTier = null,
  fromStatus = null,
  toStatus = null,
  orderId = null,
  payload = {},
}) {
  await executeSql(`
    insert into membership_events (
      subscription_id, user_id, event_type, from_tier, to_tier,
      from_status, to_status, order_id, payload_json
    )
    values (
      ${subscriptionId ? `${sqlLiteral(subscriptionId)}::uuid` : 'null'},
      ${sqlLiteral(userId)}::uuid,
      ${sqlLiteral(eventType)},
      ${sqlLiteral(fromTier)},
      ${sqlLiteral(toTier)},
      ${sqlLiteral(fromStatus)},
      ${sqlLiteral(toStatus)},
      ${orderId ? `${sqlLiteral(orderId)}::uuid` : 'null'},
      ${sqlLiteral(JSON.stringify(payload))}::jsonb
    );
  `)
}

async function syncUserMembershipLevel(userId, reason = 'sync') {
  const current = await getUserById(userId)
  if (!current) return { oldLevel: 'registered', newLevel: 'registered', changed: false }

  const next = await queryJson(`
    select to_json(coalesce((
      select tier
      from membership_subscriptions
      where user_id = ${sqlLiteral(userId)}::uuid
        and status = 'active'
        and expires_at > now()
      order by case tier when 'senior' then 2 when 'junior' then 1 else 0 end desc,
        expires_at desc
      limit 1
    ), 'registered'));
  `)
  const nextLevel = next || 'registered'

  if (current.membershipLevel !== nextLevel) {
    await executeSql(`
      update users
      set membership_level = ${sqlLiteral(nextLevel)},
          updated_at = now()
      where id = ${sqlLiteral(userId)}::uuid;
    `)

    if (membershipRank(nextLevel) < membershipRank(current.membershipLevel)) {
      await writeMembershipEvent({
        userId,
        eventType: 'downgraded',
        fromTier: current.membershipLevel,
        toTier: nextLevel,
        payload: { reason },
      })
      await queueNotificationMessage({
        userId,
        scene: 'membership_downgraded',
        templateKey: 'membership_downgraded',
        dedupeKey: `membership:${userId}:downgraded:${new Date().toISOString().slice(0, 10)}`,
        payload: {
          title: nextLevel === 'registered' ? '会员权益已停止' : `会员已降级为${membershipLabel(nextLevel)}`,
          body: nextLevel === 'registered'
            ? '会员到期后已恢复为注册用户，可重新开通会员继续使用专属权益。'
            : '高级会员到期后已保留当前仍有效的初级会员权益。',
          actionUrl: '/membership',
          oldLevel: current.membershipLevel,
          newLevel: nextLevel,
        },
      })
    }
  }

  return {
    oldLevel: current.membershipLevel,
    newLevel: nextLevel,
    changed: current.membershipLevel !== nextLevel,
  }
}

function productUsesMemberPrice(productType) {
  return ['agent', 'course', 'event'].includes(productType)
}

export async function previewCheckout(token, payload) {
  const { user } = await requireUser(token)
  const membership = await resolveUserMembership(user.id)
  const pricingUser = { ...user, membershipLevel: membership.level, resolvedMembership: membership }
  const items = await normalizeCheckoutItems(payload.items, pricingUser)
  return buildCheckoutPreview(pricingUser, items, payload.couponCode, '', null, membership)
}

async function buildCheckoutPreview(user, items, couponCode = '', couponId = '', couponOverride = null, membershipOverride = null) {
  const subtotalAmount = items.reduce((sum, item) => sum + item.unitPriceAmount * item.quantity, 0)
  const couponPreview = await resolveCouponPreview(couponCode, items, user, couponId, couponOverride)
  const couponEligibleProductIds = new Set(couponPreview?.eligibleItems?.map((item) => item.productId) || [])
  const allowMemberDiscount = !couponPreview || couponPreview.stackingPolicy?.allowMemberDiscount !== false
  const memberDiscountAmount = items.reduce((sum, item) => {
    if (item.memberDiscountEligible === false) return sum
    if (!allowMemberDiscount && couponEligibleProductIds.has(item.productId)) return sum
    return sum + Math.round(item.unitPriceAmount * item.quantity * memberDiscountRate(user.membershipLevel))
  }, 0)
  const couponDiscountAmount = couponPreview
    ? finalizeCouponDiscount(couponPreview, items, user, memberDiscountAmount)
    : 0
  const totalAmount = Math.max(subtotalAmount - memberDiscountAmount - couponDiscountAmount, 0)
  const finalCouponPreview = couponPreview
    ? {
      ...couponPreview,
      discountAmount: couponDiscountAmount,
    }
    : null
  const settlementPreview = buildExpertSettlementPreview(
    items,
    subtotalAmount,
    memberDiscountAmount + couponDiscountAmount,
  )
  const membership = membershipOverride || user.resolvedMembership || {
    level: user.membershipLevel || 'registered',
    status: user.membershipLevel && user.membershipLevel !== 'registered' ? 'active' : 'registered',
    subscription: null,
    activeSubscriptions: [],
    expiresAt: null,
    daysLeft: null,
  }
  const itemBreakdowns = items.map((item) => ({
    code: `${item.productType}:${item.productId}`,
    label: item.titleSnapshot,
    amount: Math.max(0, Number(item.memberDiscountAmount || 0)) * Number(item.quantity || 1),
    source: item.pricingSource || 'matrix',
  })).filter((item) => item.amount > 0)
  const pricingBreakdown = [
    ...itemBreakdowns,
    ...(memberDiscountAmount > 0 ? [{
      code: 'membership_generic_discount',
      label: '会员通用折扣',
      amount: memberDiscountAmount,
      source: 'member_discount',
    }] : []),
    ...(couponDiscountAmount > 0 ? [{
      code: 'coupon_discount',
      label: couponPreview?.name || couponPreview?.code || '优惠券',
      amount: couponDiscountAmount,
      source: 'coupon',
    }] : []),
  ]
  const membershipChange = items.find((item) => item.membershipChange)?.membershipChange || null

  return {
    items,
    subtotalAmount,
    memberDiscountAmount,
    couponDiscountAmount,
    couponPreview: finalCouponPreview,
    paidAmount: totalAmount,
    totalAmount,
    currency: 'CNY',
    membership,
    pricingBreakdown,
    membershipChange,
    settlementPreview,
  }
}

export async function createConsultingCheckout(token, requestId, payload = {}) {
  const { user } = await requireUser(token)
  const requestWhere = isUuid(requestId)
    ? `id = ${sqlLiteral(requestId)}::uuid`
    : `(request_no = ${sqlLiteral(requestId)} or legacy_request_no = ${sqlLiteral(requestId)})`
  const request = await queryJson(`
    select ${consultingRequestJson('cr')}
    from consulting_requests cr
    where ${requestWhere}
      and cr.user_id = ${sqlLiteral(user.id)}::uuid
    limit 1;
  `)
  if (!request) {
    const error = new Error('规划咨询申请不存在')
    error.statusCode = 404
    error.code = 'consulting_not_found'
    throw error
  }
  if (request.orderId) {
    const existingOrder = await getOrderById(request.orderId)
    if (existingOrder) return existingOrder
  }
  if (request.status !== 'proposal_ready' || !request.chargeRequired || request.quotedAmount <= 0) {
    const error = new Error('规划咨询尚未生成可支付报价')
    error.statusCode = 409
    error.code = 'consulting_not_payable'
    throw error
  }
  return createOrder(token, {
    consultingRequestId: request.id,
    items: [{ productId: 'consulting-service-custom', quantity: 1, consultingRequestId: request.id }],
    invoiceRequest: payload.invoiceRequest || payload.invoice || payload.invoiceApplication || payload.invoice_application,
  })
}

export async function createOrder(token, payload) {
  const preview = await previewCheckout(token, payload)
  const { user } = await requireUser(token)
  const membership = await resolveUserMembership(user.id)
  user.membershipLevel = membership.level
  const expertBookingId = isUuid(payload.expertBookingId || payload.expert_booking_id)
    ? String(payload.expertBookingId || payload.expert_booking_id)
    : null
  const previewDataServiceRequestId = preview.items.find((item) => item.dataServiceRequestId)?.dataServiceRequestId
  const dataServiceRequestId = isUuid(payload.dataServiceRequestId || payload.data_service_request_id || previewDataServiceRequestId)
    ? String(payload.dataServiceRequestId || payload.data_service_request_id || previewDataServiceRequestId)
    : null
  const previewConsultingRequestId = preview.items.find((item) => item.consultingRequestId)?.consultingRequestId
  const consultingRequestId = isUuid(payload.consultingRequestId || payload.consulting_request_id || previewConsultingRequestId)
    ? String(payload.consultingRequestId || payload.consulting_request_id || previewConsultingRequestId)
    : null
  const invoiceRequest = normalizeOptionalInvoiceRequest(payload)
  if (expertBookingId) {
    const booking = await queryJson(`
      select json_build_object('id', id, 'userId', user_id, 'status', status)
      from expert_bookings
      where id = ${sqlLiteral(expertBookingId)}::uuid
        and user_id = ${sqlLiteral(user.id)}::uuid
        and status = 'pending_payment'
      limit 1;
    `)
    if (!booking) {
      const error = new Error('专家预约单不存在或不可结算')
      error.statusCode = 409
      error.code = 'expert_booking_not_payable'
      throw error
    }
  }
  if (dataServiceRequestId) {
    const dataService = await queryJson(`
      select json_build_object('id', id, 'userId', user_id, 'status', status, 'orderId', order_id, 'quotedAmount', quoted_amount)
      from data_service_requests
      where id = ${sqlLiteral(dataServiceRequestId)}::uuid
        and user_id = ${sqlLiteral(user.id)}::uuid
        and status = 'quoted'
        and order_id is null
      limit 1;
    `)
    if (!dataService) {
      const error = new Error('数据服务申请不存在、已结算或尚未报价')
      error.statusCode = 409
      error.code = 'data_service_not_payable'
      throw error
    }
  }
  if (consultingRequestId) {
    const consulting = await queryJson(`
      select json_build_object('id', id, 'userId', user_id, 'status', status, 'orderId', order_id, 'quotedAmount', quoted_amount)
      from consulting_requests
      where id = ${sqlLiteral(consultingRequestId)}::uuid
        and user_id = ${sqlLiteral(user.id)}::uuid
        and status = 'proposal_ready'
        and charge_required = true
        and order_id is null
      limit 1;
    `)
    if (!consulting) {
      const error = new Error('规划咨询申请不存在、已结算或尚未报价')
      error.statusCode = 409
      error.code = 'consulting_not_payable'
      throw error
    }
  }
  const status = preview.totalAmount === 0 ? 'paid' : 'pending_payment'
  const settlementSnapshot = {
    ...preview,
    expertBookingId,
    dataServiceRequestId,
    consultingRequestId,
    ...(invoiceRequest ? { invoiceRequest } : {}),
  }

  const order = await createCheckoutOrderRecord({
    user,
    preview,
    status,
    settlementSnapshot,
    expertBookingId,
    dataServiceRequestId,
    consultingRequestId,
  })

  return getOrderById(order.id)
}

function normalizePaymentChannel(channel) {
  const value = String(channel || '').trim()
  if (paymentChannels.has(value)) return value
  return isProduction ? 'wechat_native' : 'dev'
}

function assertPaymentChannelAllowed(channel) {
  if (channel === 'dev') {
    if (!devPaymentEnabled) {
      const error = new Error('开发支付接口未启用')
      error.statusCode = 403
      error.code = 'dev_payment_disabled'
      throw error
    }
    return
  }
  if (!wechatPayEnabled()) {
    const error = new Error('微信支付未启用')
    error.statusCode = 503
    error.code = 'wechat_pay_disabled'
    throw error
  }
}

function paymentResponse(paymentOrder, order, extra = {}) {
  return {
    paymentOrder: {
      ...paymentOrder,
      ...extra,
    },
    order,
  }
}

function orderPaymentDescription(order) {
  const titles = (order.items || []).map((item) => item.titleSnapshot).filter(Boolean)
  const summary = titles.length > 0 ? titles.slice(0, 2).join(' / ') : '平台服务'
  return `大连市大数据研究院-${summary}`
}

async function getMiniappPaymentOpenid(userId) {
  const config = wechatPayConfig()
  return findMiniappPaymentOpenidRecord(userId, config.appId)
}

async function failPaymentOrder(paymentNo, error) {
  await failPaymentOrderRecord(paymentNo, error)
  metric('dlbdi_payment_orders_total', { channel: 'unknown', status: 'failed' })
  await recordOperationalEvent({
    severity: 'error',
    eventType: 'payment.order.failed',
    resourceType: 'payment_order',
    resourceId: paymentNo,
    message: error.message || '支付单处理失败',
    payload: {
      code: error.code || 'payment_failed',
      details: error.details || {},
    },
  })
}

async function expirePaymentIfNeeded(payment) {
  if (!payment) return null
  const expiresAt = new Date(payment.expiresAt).getTime()
  if ((payment.status === 'created' || payment.status === 'paying') && Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
    await expirePaymentRecord(payment)
    await releaseExpertBookingHoldByOrder(payment.orderId, '支付单超时，预约锁位已释放')
    metric('dlbdi_payment_orders_total', { channel: payment.channel || 'unknown', status: 'expired' })
    const existingEvent = await findOperationalEventByFingerprint('payment.order.timeout', payment.paymentNo)
    if (!existingEvent || !['open', 'acknowledged'].includes(existingEvent.status)) {
      await recordOperationalEvent({
        severity: 'warning',
        eventType: 'payment.order.timeout',
        resourceType: 'payment_order',
        resourceId: payment.paymentNo,
        message: '支付单超过有效期仍未确认',
        payload: {
          paymentNo: payment.paymentNo,
          channel: payment.channel,
          status: payment.status,
          expiresAt: payment.expiresAt,
        },
      })
    }
    return getPaymentByNo(payment.paymentNo)
  }
  return payment
}

export async function createPaymentOrder(token, payload) {
  const { user } = await requireUser(token)
  const order = await getUserOrder(user.id, payload.orderNo || payload.orderId)
  if (!order) {
    const error = new Error('订单不存在')
    error.statusCode = 404
    error.code = 'order_not_found'
    throw error
  }

  if (order.status !== 'pending_payment') {
    const error = new Error('该订单当前不需要支付')
    error.statusCode = 400
      error.code = 'order_not_payable'
      throw error
    }

  const channel = normalizePaymentChannel(payload.channel)
  assertPaymentChannelAllowed(channel)

  const existing = await findActivePaymentForOrderChannel(order.id, channel)

  if (existing) {
    if (channel === 'dev') {
      return paymentResponse(existing, order)
    }
    if (channel === 'wechat_jsapi' && existing.prepayId) {
      return paymentResponse(existing, order, { jsapiParams: createJsapiPaymentParams(existing.prepayId) })
    }
    if (channel === 'wechat_native' && existing.codeUrl) {
      return paymentResponse(existing, order)
    }
    await failPaymentOrder(existing.paymentNo, {
      code: 'payment_order_not_initialized',
      message: '上一笔支付单未完成初始化，已重新生成',
    })
  }

  const { paymentNo } = await createPaymentOrderRecord({
    orderId: order.id,
    channel,
    amount: order.totalAmount,
  })
  metric('dlbdi_payment_orders_total', { channel, status: 'created' })
  const paymentOrder = await getPaymentByNo(paymentNo)
  await syncExpertBookingHoldWithPayment(paymentOrder)

  if (channel === 'dev') return paymentResponse(paymentOrder, order)

  try {
    if (channel === 'wechat_jsapi') {
      const openid = await getMiniappPaymentOpenid(user.id)
      const result = await createWechatJsapiPayment({
        paymentNo,
        amount: order.totalAmount,
        description: orderPaymentDescription(order),
        openid,
        expiresAt: paymentOrder.expiresAt,
      })
      await markWechatJsapiPaymentPaying(paymentNo, { openid, result })
      metric('dlbdi_payment_orders_total', { channel, status: 'paying' })
      return paymentResponse(await getPaymentByNo(paymentNo), order, { jsapiParams: result.jsapiParams })
    }

    const result = await createWechatNativePayment({
      paymentNo,
      amount: order.totalAmount,
      description: orderPaymentDescription(order),
      expiresAt: paymentOrder.expiresAt,
    })
    await markWechatNativePaymentPaying(paymentNo, { result })
    metric('dlbdi_payment_orders_total', { channel, status: 'paying' })
    return paymentResponse(await getPaymentByNo(paymentNo), order, { codeUrl: result.codeUrl })
  } catch (error) {
    await failPaymentOrder(paymentNo, error)
    throw error
  }
}

export async function succeedDevPayment(token, paymentNo) {
  if (!devPaymentEnabled) {
    const error = new Error('开发支付接口未启用')
    error.statusCode = 403
    error.code = 'dev_payment_disabled'
    throw error
  }
  const { actor } = await requireAdmin(token, 'payment:dev')
  const payment = await getPaymentByNo(paymentNo)

  if (!payment) {
    const error = new Error('支付单不存在')
    error.statusCode = 404
    error.code = 'payment_not_found'
      throw error
    }

  if (payment.channel !== 'dev') {
    const error = new Error('该支付单不是开发支付渠道')
    error.statusCode = 400
    error.code = 'payment_channel_not_dev'
    throw error
  }

  const result = await markPaymentSucceeded(payment, {
    channelTradeNo: '',
    notifyPayload: { source: 'dev' },
  })
  await auditAdmin(actor, 'payment.succeed', 'payment_order', payment.paymentNo, { orderNo: result.order.orderNo })

  return result
}

async function markPaymentSucceeded(payment, options = {}) {
  const channelTradeNo = options.channelTradeNo || payment.channelTradeNo || ''
  const notifyPayload = options.notifyPayload || {}
  let transactionResult
  try {
    transactionResult = await markPaymentSucceededAndGrant(payment, {
      channelTradeNo,
      notifyPayload,
    })
  } catch (error) {
    await recordOperationalEvent({
      severity: 'critical',
      eventType: 'payment.succeed_transaction_failed',
      resourceType: 'payment_order',
      resourceId: payment.paymentNo || payment.id || '',
      message: '支付成功事务处理失败',
      payload: {
        code: error.code || 'payment_succeed_transaction_failed',
        message: error.message || '',
        paymentNo: payment.paymentNo || '',
        orderId: payment.orderId || '',
      },
    })
    throw error
  }
  metric('dlbdi_payment_orders_total', { channel: payment.channel || 'unknown', status: 'succeeded' })

  return {
    paymentOrder: await getPaymentByNo(transactionResult.paymentNo),
    order: await getOrderById(transactionResult.orderId),
  }
}

export async function getPaymentStatus(token, paymentNo) {
  const { user } = await requireUser(token)
  let payment = await findUserPaymentByNo(user.id, paymentNo)
  payment = await expirePaymentIfNeeded(payment)
  if (!payment) {
    const error = new Error('支付单不存在')
    error.statusCode = 404
    error.code = 'payment_not_found'
    throw error
  }
  const order = await getUserOrder(user.id, payment.orderId)
  return {
    paymentOrder: payment,
    order,
    canRedirect: payment.status === 'succeeded' && order?.status === 'paid',
  }
}

export async function cancelPaymentOrder(token, paymentNo) {
  const { user } = await requireUser(token)
  let current = await findUserPaymentByNo(user.id, paymentNo)
  current = await expirePaymentIfNeeded(current)
  if (!current) {
    const error = new Error('支付单不存在')
    error.statusCode = 404
    error.code = 'payment_not_found'
    throw error
  }
  if (current.provider === 'wechat' && ['created', 'paying'].includes(current.status)) {
    await closeWechatPayment(current.paymentNo)
  }
  const payment = await closeUserPaymentRecord(user.id, paymentNo)
  if (!payment) {
    const error = new Error('支付单不存在')
    error.statusCode = 404
    error.code = 'payment_not_found'
    throw error
  }
  await releaseExpertBookingHoldByOrder(payment.orderId, '用户取消支付，预约锁位已释放')
  const order = await getUserOrder(user.id, payment.orderId)
  return {
    paymentOrder: payment,
    order,
    canRedirect: payment.status === 'succeeded' && order?.status === 'paid',
  }
}

export async function handleWechatPayNotify(headers, rawBody) {
  metric('dlbdi_wechat_pay_notify_total', { status: 'received', reason: 'incoming' })
  if (!wechatPayEnabled()) {
    metric('dlbdi_wechat_pay_notify_total', { status: 'failed', reason: 'disabled' })
    const error = new Error('微信支付未启用')
    error.statusCode = 503
    error.code = 'wechat_pay_disabled'
    throw error
  }
  const config = wechatPayConfig()
  let payload
  let resource
  try {
    ;({ payload, resource } = verifyWechatPayNotification(headers, rawBody))
  } catch (error) {
    metric('dlbdi_wechat_pay_notify_total', { status: 'failed', reason: error.code || 'verify_failed' })
    await recordOperationalEvent({
      severity: 'critical',
      eventType: 'wechat_pay.notify.failed',
      resourceType: 'wechat_pay_notify',
      resourceId: '',
      message: error.message || '微信支付回调验签失败',
      payload: { code: error.code || 'verify_failed' },
    })
    throw error
  }
  const paymentNo = resource.out_trade_no || ''
  if (!paymentNo) {
    metric('dlbdi_wechat_pay_notify_total', { status: 'failed', reason: 'out_trade_no_missing' })
    const error = new Error('微信支付回调缺少商户支付单号')
    error.statusCode = 400
    error.code = 'wechat_pay_out_trade_no_missing'
    throw error
  }
  const payment = await getPaymentByNo(paymentNo)
  if (!payment) {
    metric('dlbdi_wechat_pay_notify_total', { status: 'failed', reason: 'payment_not_found' })
    const error = new Error('支付单不存在')
    error.statusCode = 404
    error.code = 'payment_not_found'
    throw error
  }
  if (resource.mchid !== config.mchId || resource.appid !== config.appId) {
    metric('dlbdi_wechat_pay_notify_total', { status: 'failed', reason: 'merchant_mismatch' })
    await recordOperationalEvent({
      severity: 'critical',
      eventType: 'wechat_pay.notify.failed',
      resourceType: 'payment_order',
      resourceId: paymentNo,
      message: '微信支付商户号或 AppID 不匹配',
      payload: { mchid: resource.mchid, appid: resource.appid },
    })
    const error = new Error('微信支付商户号或 AppID 不匹配')
    error.statusCode = 400
    error.code = 'wechat_pay_merchant_mismatch'
    throw error
  }
  const paidAmount = Number(resource.amount?.total ?? resource.amount?.payer_total ?? NaN)
  if (!Number.isFinite(paidAmount) || paidAmount !== payment.amount) {
    metric('dlbdi_wechat_pay_notify_total', { status: 'failed', reason: 'amount_mismatch' })
    await recordOperationalEvent({
      severity: 'critical',
      eventType: 'wechat_pay.notify.failed',
      resourceType: 'payment_order',
      resourceId: paymentNo,
      message: '微信支付回调金额不匹配',
      payload: { paidAmount, expectedAmount: payment.amount },
    })
    const error = new Error('微信支付回调金额不匹配')
    error.statusCode = 400
    error.code = 'wechat_pay_amount_mismatch'
    throw error
  }
  if (resource.trade_state !== 'SUCCESS') {
    metric('dlbdi_wechat_pay_notify_total', { status: 'failed', reason: resource.trade_state || 'not_success' })
    const error = new Error(resource.trade_state_desc || '微信支付尚未成功')
    error.statusCode = 400
    error.code = 'wechat_pay_not_success'
    throw error
  }

  const result = await markPaymentSucceeded(payment, {
    channelTradeNo: resource.transaction_id || '',
    notifyPayload: {
      event: payload,
      transaction: resource,
    },
  })
  metric('dlbdi_wechat_pay_notify_total', { status: 'succeeded', reason: 'success' })
  return result
}

export async function listAccountOrders(token) {
  const { user } = await requireUser(token)
  return listUserOrders(user.id)
}

export async function getAccountOrder(token, orderKey) {
  const { user } = await requireUser(token)
  return getUserOrder(user.id, orderKey)
}

export async function listAccountDataProducts(token) {
  const { user } = await requireUser(token)
  return queryJson(`
    select coalesce(json_agg(owned.item order by owned.last_purchased_at desc), '[]'::json)
    from (
      select distinct on (p.id)
        ${dataProductJson('p', 'd')} as item,
        o.created_at as last_purchased_at
      from order_items oi
      join orders o on o.id = oi.order_id
      join products p on p.id = oi.product_id and p.type = 'data'
      join data_product_details d on d.product_id = p.id
      where o.user_id = ${sqlLiteral(user.id)}::uuid
        and o.status = 'paid'
        and oi.product_type = 'data'
      order by p.id, o.created_at desc
    ) owned;
  `)
}

export async function listAccountCourseRegistrations(token) {
  const { user } = await requireUser(token)
  return queryJson(`
    select coalesce(json_agg(registration_json order by (registration_json->'course'->>'startsAt') asc nulls last, (registration_json->>'createdAt') desc), '[]'::json)
    from (
      select ${courseRegistrationJson('cr', 'u', 'p', 'd', 'o', 'sa')} as registration_json
      from course_registrations cr
      join users u on u.id = cr.user_id
      join products p on p.id = cr.product_id and p.type = 'course'
      join course_product_details d on d.product_id = p.id
      left join orders o on o.id = cr.order_id
      left join staff_accounts sa on sa.id = cr.checked_in_by_staff_id
      where cr.user_id = ${sqlLiteral(user.id)}::uuid
        and cr.status <> 'cancelled'
      union all
      select synthetic.registration_json
      from (
        select distinct on (p.id) json_build_object(
          'id', p.id,
          'userId', u.id,
          'userName', u.display_name,
          'userPhone', u.phone,
          'userOrganization', u.organization,
          'membershipLevel', u.membership_level,
          'productId', p.id,
          'orderId', o.id,
          'orderNo', o.order_no,
          'registrationNo', 'COURSE-' || p.id,
          'status', 'registered',
          'checkinCode', '',
          'checkedInAt', null,
          'checkinSource', '',
          'checkinNote', '',
          'checkedInByStaffId', null,
          'checkedInByStaffName', null,
          'createdAt', o.created_at,
          'updatedAt', o.updated_at,
          'course', ${courseProductJson('p', 'd')}
        ) as registration_json
        from order_items oi
        join orders o on o.id = oi.order_id and o.status = 'paid'
        join users u on u.id = o.user_id
        join products p on p.id = oi.product_id and p.type = 'course'
        join course_product_details d on d.product_id = p.id
        where o.user_id = ${sqlLiteral(user.id)}::uuid
          and oi.product_type = 'course'
          and not exists (
            select 1
            from course_registrations existing_cr
            where existing_cr.user_id = o.user_id
              and existing_cr.product_id = p.id
              and existing_cr.status <> 'cancelled'
          )
        order by p.id, o.created_at desc
      ) synthetic
    ) t;
  `)
}

export async function getAccountCourseRegistration(token, registrationId) {
  const { user } = await requireUser(token)
  const registration = await queryJson(`
    select ${courseRegistrationJson('cr', 'u', 'p', 'd', 'o', 'sa')}
    from course_registrations cr
    join users u on u.id = cr.user_id
    join products p on p.id = cr.product_id and p.type = 'course'
    join course_product_details d on d.product_id = p.id
    left join orders o on o.id = cr.order_id
    left join staff_accounts sa on sa.id = cr.checked_in_by_staff_id
    where ${registrationWhere(registrationId, 'cr')}
      and cr.user_id = ${sqlLiteral(user.id)}::uuid
      and cr.status <> 'cancelled'
    limit 1;
  `)
  if (registration) return registration
  return queryJson(`
    select json_build_object(
      'id', p.id,
      'userId', u.id,
      'userName', u.display_name,
      'userPhone', u.phone,
      'userOrganization', u.organization,
      'membershipLevel', u.membership_level,
      'productId', p.id,
      'orderId', o.id,
      'orderNo', o.order_no,
      'registrationNo', 'COURSE-' || p.id,
      'status', 'registered',
      'checkinCode', '',
      'checkedInAt', null,
      'checkinSource', '',
      'checkinNote', '',
      'checkedInByStaffId', null,
      'checkedInByStaffName', null,
      'createdAt', o.created_at,
      'updatedAt', o.updated_at,
      'course', ${courseProductJson('p', 'd')}
    )
    from order_items oi
    join orders o on o.id = oi.order_id and o.status = 'paid'
    join users u on u.id = o.user_id
    join products p on p.id = oi.product_id and p.type = 'course'
    join course_product_details d on d.product_id = p.id
    where o.user_id = ${sqlLiteral(user.id)}::uuid
      and oi.product_type = 'course'
      and (p.id::text = ${sqlLiteral(registrationId)} or p.slug = ${sqlLiteral(registrationId)})
      and not exists (
        select 1
        from course_registrations existing_cr
        where existing_cr.user_id = o.user_id
          and existing_cr.product_id = p.id
          and existing_cr.status <> 'cancelled'
      )
    order by o.created_at desc
    limit 1;
  `)
}

export async function listAccountEventRegistrations(token) {
  const { user } = await requireUser(token)
  return queryJson(`
    select coalesce(json_agg(registration_json order by (registration_json->'event'->>'startsAt') asc nulls last, (registration_json->>'createdAt') desc), '[]'::json)
    from (
      select ${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')} as registration_json
      from event_registrations er
      join users u on u.id = er.user_id
      join products p on p.id = er.product_id and p.type = 'event'
      join event_product_details d on d.product_id = p.id
      left join orders o on o.id = er.order_id
      left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
      where er.user_id = ${sqlLiteral(user.id)}::uuid
        and er.status <> 'cancelled'
    ) t;
  `)
}

export async function getAccountEventRegistration(token, registrationId) {
  const { user } = await requireUser(token)
  return queryJson(`
    select ${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')}
    from event_registrations er
    join users u on u.id = er.user_id
    join products p on p.id = er.product_id and p.type = 'event'
    join event_product_details d on d.product_id = p.id
    left join orders o on o.id = er.order_id
    left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
    where ${registrationWhere(registrationId, 'er')}
      and er.user_id = ${sqlLiteral(user.id)}::uuid
      and er.status <> 'cancelled'
    limit 1;
  `)
}

export async function listAdminEventRegistrations(token, filters = {}) {
  await requireAdminAny(token, ['event_registration:read', 'event:read', 'product:read'])
  const productFilter = filters.productId ? `and er.product_id = ${sqlLiteral(filters.productId)}` : ''
  const statusFilter = filters.status ? `and er.status = ${sqlLiteral(filters.status)}` : ''
  return queryJson(`
    select coalesce(json_agg(registration_json order by (registration_json->'event'->>'startsAt') asc nulls last, (registration_json->>'createdAt') desc), '[]'::json)
    from (
      select ${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')} as registration_json
      from event_registrations er
      join users u on u.id = er.user_id
      join products p on p.id = er.product_id and p.type = 'event'
      join event_product_details d on d.product_id = p.id
      left join orders o on o.id = er.order_id
      left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
      where ${filters.status ? 'true' : "er.status <> 'cancelled'"}
        ${productFilter}
        ${statusFilter}
    ) t;
  `)
}

export async function listAdminCourseRegistrations(token, filters = {}) {
  await requireAdmin(token, 'product:read')
  const productFilter = filters.productId ? `and cr.product_id = ${sqlLiteral(filters.productId)}` : ''
  const statusFilter = filters.status ? `and cr.status = ${sqlLiteral(filters.status)}` : ''
  return queryJson(`
    select coalesce(json_agg(registration_json order by (registration_json->'course'->>'startsAt') asc nulls last, (registration_json->>'createdAt') desc), '[]'::json)
    from (
      select ${courseRegistrationJson('cr', 'u', 'p', 'd', 'o', 'sa')} as registration_json
      from course_registrations cr
      join users u on u.id = cr.user_id
      join products p on p.id = cr.product_id and p.type = 'course'
      join course_product_details d on d.product_id = p.id
      left join orders o on o.id = cr.order_id
      left join staff_accounts sa on sa.id = cr.checked_in_by_staff_id
      where cr.status <> 'cancelled'
        ${productFilter}
        ${statusFilter}
    ) t;
  `)
}

export async function listStaffCourseProducts(token) {
  await requireAdminAny(token, ['course_registration:read', 'event:read'])
  return queryJson(`
    select coalesce(json_agg(course_json order by starts_at nulls last, sort_order, created_at), '[]'::json)
    from (
      select
        ((${courseProductJson('p', 'd')})::jsonb || jsonb_build_object(
          'registeredCount', coalesce((
            select count(*)::int
            from course_registrations cr
            where cr.product_id = p.id
              and cr.status <> 'cancelled'
          ), 0),
          'checkedInCount', coalesce((
            select count(*)::int
            from course_registrations cr
            where cr.product_id = p.id
              and cr.status = 'checked_in'
          ), 0)
        ))::json as course_json,
        d.starts_at,
        d.sort_order,
        p.created_at
      from products p
      join course_product_details d on d.product_id = p.id
      where p.type = 'course'
        and p.status <> 'draft'
        and d.status <> 'deleted'
        and d.course_format = '线下培训'
        and d.checkin_required = true
    ) t;
  `)
}

export async function listStaffCourseRegistrations(token, filters = {}) {
  await requireAdminAny(token, ['course_registration:read', 'event:read'])
  const productFilter = filters.productId ? `and cr.product_id = ${sqlLiteral(filters.productId)}` : ''
  const statusFilter = filters.status ? `and cr.status = ${sqlLiteral(filters.status)}` : ''
  return queryJson(`
    select coalesce(json_agg(registration_json order by (registration_json->>'status') asc, (registration_json->>'createdAt') desc), '[]'::json)
    from (
      select ${courseRegistrationJson('cr', 'u', 'p', 'd', 'o', 'sa')} as registration_json
      from course_registrations cr
      join users u on u.id = cr.user_id
      join products p on p.id = cr.product_id and p.type = 'course'
      join course_product_details d on d.product_id = p.id
      left join orders o on o.id = cr.order_id
      left join staff_accounts sa on sa.id = cr.checked_in_by_staff_id
      where ${filters.status || filters.includeCancelled ? 'true' : "cr.status <> 'cancelled'"}
        ${productFilter}
        ${statusFilter}
    ) t;
  `)
}

export async function createStaffCourseRegistration(token, courseId, payload = {}) {
  const { actor, account } = await requireAdminAny(token, ['course_checkin:update', 'event_registration:update', 'event_checkin:update', 'event:checkin', 'event:write'])
  const productId = String(courseId || payload.courseId || payload.productId || '').trim()
  const passportNo = normalizePassportNo(payload.passportNo || payload.passport_no || payload.code || payload.result)
  const phone = normalizePhone(payload.phone || '')
  const note = String(payload.note || payload.checkinNote || '').trim()
  if (!productId) {
    const error = new Error('课程不能为空')
    error.statusCode = 400
    error.code = 'missing_course_id'
    throw error
  }
  if (!passportNo && !phone) {
    const error = new Error('请输入通行证号或手机号')
    error.statusCode = 400
    error.code = 'missing_registration_user_key'
    throw error
  }

  const course = await queryJson(`
    select ${courseProductJson('p', 'd')}
    from products p
    join course_product_details d on d.product_id = p.id
    where p.id = ${sqlLiteral(productId)}
      and p.type = 'course'
    limit 1;
  `)
  if (!course) {
    const error = new Error('课程不存在')
    error.statusCode = 404
    error.code = 'course_not_found'
    throw error
  }

  const user = await queryJson(`
    select ${userJson('u')}
    from users u
    where (${passportNo ? `u.passport_no = ${sqlLiteral(passportNo)}` : 'false'})
       or (${phone ? `u.phone = ${sqlLiteral(phone)}` : 'false'})
    limit 1;
  `)
  if (!user) {
    const error = new Error('未找到该通行证对应用户')
    error.statusCode = 404
    error.code = 'registration_user_not_found'
    throw error
  }

  const existing = await queryJson(`
    select ${courseRegistrationJson('cr', 'u', 'p', 'd', 'o', 'sa')}
    from course_registrations cr
    join users u on u.id = cr.user_id
    join products p on p.id = cr.product_id and p.type = 'course'
    join course_product_details d on d.product_id = p.id
    left join orders o on o.id = cr.order_id
    left join staff_accounts sa on sa.id = cr.checked_in_by_staff_id
    where cr.product_id = ${sqlLiteral(productId)}
      and cr.user_id = ${sqlLiteral(user.id)}::uuid
      and cr.status <> 'cancelled'
    order by cr.created_at desc
    limit 1;
  `)
  if (existing) {
    if (payload.checkIn || payload.checkin || payload.immediateCheckin) {
      const checked = await checkInCourseRegistrationById(token, existing.id, {
        courseId: productId,
        source: payload.source || 'admin_passport',
        note: note || '现场通行证补录后核销',
      })
      return { ...checked, alreadyRegistered: true }
    }
    return { ...existing, alreadyRegistered: true }
  }

  const registrationNo = await nextBusinessNo('REG')
  const code = checkinCode('CRS')
  const created = await queryJson(`
    insert into course_registrations (
      user_id,
      product_id,
      order_id,
      checkin_code,
      registration_no,
      status,
      checkin_source,
      checkin_note
    )
    values (
      ${sqlLiteral(user.id)}::uuid,
      ${sqlLiteral(productId)},
      null,
      ${sqlLiteral(code)},
      ${sqlLiteral(registrationNo)},
      'registered',
      ${sqlLiteral(payload.source || 'admin_manual')},
      ${sqlLiteral(note)}
    )
    returning json_build_object('id', id);
  `)

  let registration = await getStaffCourseRegistration(token, created.id)
  await auditAdmin(actor, 'course_registration.manual_create', 'course_registration', registration.id, {
    courseId: productId,
    passportNo,
    phoneTail: user.phone ? String(user.phone).slice(-4) : '',
    checkIn: Boolean(payload.checkIn || payload.checkin || payload.immediateCheckin),
  })

  if (payload.checkIn || payload.checkin || payload.immediateCheckin) {
    registration = await checkInCourseRegistrationById(token, registration.id, {
      courseId: productId,
      source: payload.source || 'admin_passport',
      note: note || '现场通行证补录后核销',
    })
  } else {
    await auditStaff({
      actorAccount: account,
      action: 'course.registration.manual_create',
      targetType: 'course_registration',
      targetId: registration.id,
      summary: `现场补录课程报名：${registration.registrationNo}`,
      metadata: { courseId: productId, passportNo },
    })
  }

  return { ...registration, alreadyRegistered: false }
}

export async function getStaffCourseRegistration(token, registrationId) {
  await requireAdminAny(token, ['course_registration:read', 'event:read'])
  return queryJson(`
    select ${courseRegistrationJson('cr', 'u', 'p', 'd', 'o', 'sa')}
    from course_registrations cr
    join users u on u.id = cr.user_id
    join products p on p.id = cr.product_id and p.type = 'course'
    join course_product_details d on d.product_id = p.id
    left join orders o on o.id = cr.order_id
    left join staff_accounts sa on sa.id = cr.checked_in_by_staff_id
    where ${registrationWhere(registrationId, 'cr')}
      and cr.status <> 'cancelled'
    limit 1;
  `)
}

export async function checkInCourseRegistrationByCode(token, payload = {}) {
  const { account } = await requireAdminAny(token, ['course_checkin:update', 'event_checkin:update', 'event:checkin'])
  const code = normalizeCheckinCode(payload.checkinCode || payload.code || payload.result)
  if (!code) {
    const error = new Error('核销码不能为空')
    error.statusCode = 400
    error.code = 'empty_checkin_code'
    throw error
  }

  const registration = await queryJson(`
    select ${courseRegistrationJson('cr', 'u', 'p', 'd', 'o', 'sa')}
    from course_registrations cr
    join users u on u.id = cr.user_id
    join products p on p.id = cr.product_id and p.type = 'course'
    join course_product_details d on d.product_id = p.id
    left join orders o on o.id = cr.order_id
    left join staff_accounts sa on sa.id = cr.checked_in_by_staff_id
    where cr.checkin_code = ${sqlLiteral(code)}
      and cr.status <> 'cancelled'
    limit 1;
  `)

  if (!registration) {
    const error = new Error('未找到对应课程报名记录')
    error.statusCode = 404
    error.code = 'course_checkin_registration_not_found'
    throw error
  }

  if (payload.courseId && registration.productId !== payload.courseId) {
    const error = new Error('核销码不属于当前课程')
    error.statusCode = 400
    error.code = 'course_checkin_mismatch'
    throw error
  }

  if (registration.status === 'checked_in') {
    await auditCheckin({
      account,
      kind: 'course',
      registration,
      source: payload.source || 'miniapp_scan',
      method: 'code',
      alreadyCheckedIn: true,
    })
    return { ...registration, alreadyCheckedIn: true }
  }
  if (registration.status !== 'registered') {
    const error = new Error('当前课程报名状态不可核销')
    error.statusCode = 409
    error.code = 'course_checkin_status_not_allowed'
    throw error
  }

  const staffIdSql = account?.id && account.id !== 'dev-admin' ? `${sqlLiteral(account.id)}::uuid` : 'null'
  const updateResult = await queryJson(`
    update course_registrations
    set status = 'checked_in',
        checked_in_at = now(),
        checked_in_by_staff_id = ${staffIdSql},
        checkin_source = ${sqlLiteral(payload.source || 'miniapp_scan')},
        checkin_note = ${sqlLiteral(payload.note || '')},
        updated_at = now()
    where id = ${sqlLiteral(registration.id)}::uuid
      and status = 'registered'
    returning json_build_object('id', id);
  `)

  const updated = await getStaffCourseRegistration(token, registration.id)
  if (updated?.status === 'checked_in' && !updateResult) {
    await auditCheckin({
      account,
      kind: 'course',
      registration: updated,
      source: payload.source || 'miniapp_scan',
      method: 'code',
      alreadyCheckedIn: true,
    })
    return { ...updated, alreadyCheckedIn: true }
  }
  if (!updated) {
    const error = new Error('未找到对应课程报名记录')
    error.statusCode = 404
    error.code = 'course_checkin_registration_not_found'
    throw error
  }
  if (!updateResult) {
    const error = new Error('当前课程报名状态不可核销')
    error.statusCode = 409
    error.code = 'course_checkin_status_not_allowed'
    throw error
  }
  await auditCheckin({
    account,
    kind: 'course',
    registration: updated,
    source: payload.source || 'miniapp_scan',
    method: 'code',
  })
  return { ...updated, alreadyCheckedIn: false }
}

export async function checkInCourseRegistrationById(token, registrationId, payload = {}) {
  const { account } = await requireAdminAny(token, ['course_checkin:update', 'event_checkin:update', 'event:checkin'])
  const registration = await getStaffCourseRegistration(token, registrationId)
  if (!registration) {
    const error = new Error('未找到对应课程报名记录')
    error.statusCode = 404
    error.code = 'course_checkin_registration_not_found'
    throw error
  }

  if (payload.courseId && registration.productId !== payload.courseId) {
    const error = new Error('报名记录不属于当前课程')
    error.statusCode = 400
    error.code = 'course_checkin_mismatch'
    throw error
  }

  if (registration.status === 'checked_in') {
    await auditCheckin({
      account,
      kind: 'course',
      registration,
      source: payload.source || 'miniapp_manual',
      method: 'manual',
      alreadyCheckedIn: true,
    })
    return { ...registration, alreadyCheckedIn: true }
  }

  if (registration.status !== 'registered') {
    const error = new Error('当前课程报名状态不可核销')
    error.statusCode = 409
    error.code = 'course_checkin_status_not_allowed'
    throw error
  }

  const staffIdSql = account?.id && account.id !== 'dev-admin' ? `${sqlLiteral(account.id)}::uuid` : 'null'
  const updateResult = await queryJson(`
    update course_registrations
    set status = 'checked_in',
        checked_in_at = now(),
        checked_in_by_staff_id = ${staffIdSql},
        checkin_source = ${sqlLiteral(payload.source || 'miniapp_manual')},
        checkin_note = ${sqlLiteral(payload.note || '')},
        updated_at = now()
    where id = ${sqlLiteral(registration.id)}::uuid
      and status = 'registered'
    returning json_build_object('id', id);
  `)

  const updated = await getStaffCourseRegistration(token, registration.id)
  if (updated?.status === 'checked_in' && !updateResult) {
    await auditCheckin({
      account,
      kind: 'course',
      registration: updated,
      source: payload.source || 'miniapp_manual',
      method: 'manual',
      alreadyCheckedIn: true,
    })
    return { ...updated, alreadyCheckedIn: true }
  }
  if (!updated) {
    const error = new Error('未找到对应课程报名记录')
    error.statusCode = 404
    error.code = 'course_checkin_registration_not_found'
    throw error
  }
  if (!updateResult) {
    const error = new Error('当前课程报名状态不可核销')
    error.statusCode = 409
    error.code = 'course_checkin_status_not_allowed'
    throw error
  }
  await auditCheckin({
    account,
    kind: 'course',
    registration: updated,
    source: payload.source || 'miniapp_manual',
    method: 'manual',
  })
  return { ...updated, alreadyCheckedIn: false }
}

export async function listStaffEventProducts(token) {
  await requireAdminAny(token, ['event_registration:read', 'event:read'])
  return queryJson(`
    select coalesce(json_agg(event_json order by sort_order, starts_at nulls last, created_at), '[]'::json)
    from (
      select
        ((${eventProductJson('p', 'd')})::jsonb || jsonb_build_object(
          'registeredCount', coalesce((
            select count(*)::int
            from event_registrations er
            where er.product_id = p.id
              and er.status <> 'cancelled'
          ), 0),
          'checkedInCount', coalesce((
            select count(*)::int
            from event_registrations er
            where er.product_id = p.id
              and er.status = 'checked_in'
          ), 0)
        ))::json as event_json,
        d.sort_order,
        d.starts_at,
        p.created_at
      from products p
      join event_product_details d on d.product_id = p.id
      where p.type = 'event'
        and d.status <> 'deleted'
        and p.status <> 'draft'
    ) t;
  `)
}

export async function listStaffEventRegistrations(token, filters = {}) {
  await requireAdminAny(token, ['event_registration:read', 'event:read'])
  const productFilter = filters.productId ? `and er.product_id = ${sqlLiteral(filters.productId)}` : ''
  const statusFilter = filters.status ? `and er.status = ${sqlLiteral(filters.status)}` : ''
  return queryJson(`
    select coalesce(json_agg(registration_json order by (registration_json->>'status') asc, (registration_json->>'createdAt') desc), '[]'::json)
    from (
      select ${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')} as registration_json
      from event_registrations er
      join users u on u.id = er.user_id
      join products p on p.id = er.product_id and p.type = 'event'
      join event_product_details d on d.product_id = p.id
      left join orders o on o.id = er.order_id
      left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
      where ${filters.status || filters.includeCancelled ? 'true' : "er.status <> 'cancelled'"}
        ${productFilter}
        ${statusFilter}
    ) t;
  `)
}

export async function createStaffEventRegistration(token, eventId, payload = {}) {
  const { actor, account } = await requireAdminAny(token, ['event_registration:update', 'event_checkin:update', 'event:checkin', 'event:write'])
  const productId = String(eventId || payload.eventId || payload.productId || '').trim()
  const passportNo = normalizePassportNo(payload.passportNo || payload.passport_no || payload.code || payload.result)
  const phone = normalizePhone(payload.phone || '')
  const note = String(payload.note || payload.checkinNote || '').trim()
  if (!productId) {
    const error = new Error('活动不能为空')
    error.statusCode = 400
    error.code = 'missing_event_id'
    throw error
  }
  if (!passportNo && !phone) {
    const error = new Error('请输入通行证号或手机号')
    error.statusCode = 400
    error.code = 'missing_registration_user_key'
    throw error
  }

  const event = await queryJson(`
    select ${eventProductJson('p', 'd')}
    from products p
    join event_product_details d on d.product_id = p.id
    where p.id = ${sqlLiteral(productId)}
      and p.type = 'event'
      and d.status <> 'deleted'
    limit 1;
  `)
  if (!event) {
    const error = new Error('活动不存在')
    error.statusCode = 404
    error.code = 'event_not_found'
    throw error
  }

  const user = await queryJson(`
    select ${userJson('u')}
    from users u
    where (${passportNo ? `u.passport_no = ${sqlLiteral(passportNo)}` : 'false'})
       or (${phone ? `u.phone = ${sqlLiteral(phone)}` : 'false'})
    limit 1;
  `)
  if (!user) {
    const error = new Error('未找到该通行证对应用户')
    error.statusCode = 404
    error.code = 'registration_user_not_found'
    throw error
  }

  const existing = await queryJson(`
    select ${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')}
    from event_registrations er
    join users u on u.id = er.user_id
    join products p on p.id = er.product_id and p.type = 'event'
    join event_product_details d on d.product_id = p.id
    left join orders o on o.id = er.order_id
    left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
    where er.product_id = ${sqlLiteral(productId)}
      and er.user_id = ${sqlLiteral(user.id)}::uuid
      and er.status <> 'cancelled'
    order by er.created_at desc
    limit 1;
  `)
  if (existing) {
    if (payload.checkIn || payload.checkin || payload.immediateCheckin) {
      const checked = await checkInEventRegistrationById(token, existing.id, {
        eventId: productId,
        source: payload.source || 'admin_passport',
        note: note || '现场通行证补录后核销',
      })
      return { ...checked, alreadyRegistered: true }
    }
    return { ...existing, alreadyRegistered: true }
  }

  const registrationNo = await nextBusinessNo('REG')
  const code = checkinCode('EVT')
  const created = await queryJson(`
    insert into event_registrations (
      user_id,
      product_id,
      order_id,
      checkin_code,
      registration_no,
      status,
      checkin_source,
      checkin_note
    )
    values (
      ${sqlLiteral(user.id)}::uuid,
      ${sqlLiteral(productId)},
      null,
      ${sqlLiteral(code)},
      ${sqlLiteral(registrationNo)},
      'registered',
      ${sqlLiteral(payload.source || 'admin_manual')},
      ${sqlLiteral(note)}
    )
    returning json_build_object('id', id);
  `)

  let registration = await getStaffEventRegistration(token, created.id)
  await auditAdmin(actor, 'event_registration.manual_create', 'event_registration', registration.id, {
    eventId: productId,
    passportNo,
    phoneTail: user.phone ? String(user.phone).slice(-4) : '',
    checkIn: Boolean(payload.checkIn || payload.checkin || payload.immediateCheckin),
  })

  if (payload.checkIn || payload.checkin || payload.immediateCheckin) {
    registration = await checkInEventRegistrationById(token, registration.id, {
      eventId: productId,
      source: payload.source || 'admin_passport',
      note: note || '现场通行证补录后核销',
    })
  } else {
    await auditStaff({
      actorAccount: account,
      action: 'event.registration.manual_create',
      targetType: 'event_registration',
      targetId: registration.id,
      summary: `现场补录活动报名：${registration.registrationNo}`,
      metadata: { eventId: productId, passportNo },
    })
  }

  return { ...registration, alreadyRegistered: false }
}

export async function getStaffEventRegistration(token, registrationId) {
  await requireAdminAny(token, ['event_registration:read', 'event:read'])
  return queryJson(`
    select ${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')}
    from event_registrations er
    join users u on u.id = er.user_id
    join products p on p.id = er.product_id and p.type = 'event'
    join event_product_details d on d.product_id = p.id
    left join orders o on o.id = er.order_id
    left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
    where ${registrationWhere(registrationId, 'er')}
      and er.status <> 'cancelled'
    limit 1;
  `)
}

export async function checkInEventRegistrationByCode(token, payload = {}) {
  const { account } = await requireAdminAny(token, ['event_checkin:update', 'event:checkin'])
  const code = normalizeCheckinCode(payload.checkinCode || payload.code || payload.result)
  if (!code) {
    const error = new Error('核销码不能为空')
    error.statusCode = 400
    error.code = 'empty_checkin_code'
    throw error
  }

  const registration = await queryJson(`
    select ${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')}
    from event_registrations er
    join users u on u.id = er.user_id
    join products p on p.id = er.product_id and p.type = 'event'
    join event_product_details d on d.product_id = p.id
    left join orders o on o.id = er.order_id
    left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
    where er.checkin_code = ${sqlLiteral(code)}
      and er.status <> 'cancelled'
    limit 1;
  `)

  if (!registration) {
    const error = new Error('未找到对应报名记录')
    error.statusCode = 404
    error.code = 'checkin_registration_not_found'
    throw error
  }

  if (payload.eventId && registration.productId !== payload.eventId) {
    const error = new Error('核销码不属于当前活动')
    error.statusCode = 400
    error.code = 'checkin_event_mismatch'
    throw error
  }

  if (registration.status === 'checked_in') {
    await auditCheckin({
      account,
      kind: 'event',
      registration,
      source: payload.source || 'miniapp_scan',
      method: 'code',
      alreadyCheckedIn: true,
    })
    return { ...registration, alreadyCheckedIn: true }
  }
  if (registration.status !== 'registered') {
    const error = new Error('当前报名状态不可核销')
    error.statusCode = 409
    error.code = 'checkin_status_not_allowed'
    throw error
  }

  const staffIdSql = account?.id && account.id !== 'dev-admin' ? `${sqlLiteral(account.id)}::uuid` : 'null'
  const updateResult = await queryJson(`
    update event_registrations
    set status = 'checked_in',
        checked_in_at = now(),
        checked_in_by_staff_id = ${staffIdSql},
        checkin_source = ${sqlLiteral(payload.source || 'miniapp_scan')},
        checkin_note = ${sqlLiteral(payload.note || '')},
        updated_at = now()
    where id = ${sqlLiteral(registration.id)}::uuid
      and status = 'registered'
    returning json_build_object('id', id);
  `)

  const updated = await getStaffEventRegistration(token, registration.id)
  if (updated?.status === 'checked_in' && !updateResult) {
    await auditCheckin({
      account,
      kind: 'event',
      registration: updated,
      source: payload.source || 'miniapp_scan',
      method: 'code',
      alreadyCheckedIn: true,
    })
    return { ...updated, alreadyCheckedIn: true }
  }
  if (!updated) {
    const error = new Error('未找到对应报名记录')
    error.statusCode = 404
    error.code = 'checkin_registration_not_found'
    throw error
  }
  if (!updateResult) {
    const error = new Error('当前报名状态不可核销')
    error.statusCode = 409
    error.code = 'checkin_status_not_allowed'
    throw error
  }
  await auditCheckin({
    account,
    kind: 'event',
    registration: updated,
    source: payload.source || 'miniapp_scan',
    method: 'code',
  })
  return { ...updated, alreadyCheckedIn: false }
}

export async function checkInEventRegistrationById(token, registrationId, payload = {}) {
  const { account } = await requireAdminAny(token, ['event_checkin:update', 'event:checkin'])
  const registration = await getStaffEventRegistration(token, registrationId)
  if (!registration) {
    const error = new Error('未找到对应报名记录')
    error.statusCode = 404
    error.code = 'checkin_registration_not_found'
    throw error
  }

  if (payload.eventId && registration.productId !== payload.eventId) {
    const error = new Error('报名记录不属于当前活动')
    error.statusCode = 400
    error.code = 'checkin_event_mismatch'
    throw error
  }

  if (registration.status === 'checked_in') {
    await auditCheckin({
      account,
      kind: 'event',
      registration,
      source: payload.source || 'miniapp_manual',
      method: 'manual',
      alreadyCheckedIn: true,
    })
    return { ...registration, alreadyCheckedIn: true }
  }

  if (registration.status !== 'registered') {
    const error = new Error('当前报名状态不可核销')
    error.statusCode = 409
    error.code = 'checkin_status_not_allowed'
    throw error
  }

  const staffIdSql = account?.id && account.id !== 'dev-admin' ? `${sqlLiteral(account.id)}::uuid` : 'null'
  const updateResult = await queryJson(`
    update event_registrations
    set status = 'checked_in',
        checked_in_at = now(),
        checked_in_by_staff_id = ${staffIdSql},
        checkin_source = ${sqlLiteral(payload.source || 'miniapp_manual')},
        checkin_note = ${sqlLiteral(payload.note || '')},
        updated_at = now()
    where id = ${sqlLiteral(registration.id)}::uuid
      and status = 'registered'
    returning json_build_object('id', id);
  `)

  const updated = await getStaffEventRegistration(token, registration.id)
  if (updated?.status === 'checked_in' && !updateResult) {
    await auditCheckin({
      account,
      kind: 'event',
      registration: updated,
      source: payload.source || 'miniapp_manual',
      method: 'manual',
      alreadyCheckedIn: true,
    })
    return { ...updated, alreadyCheckedIn: true }
  }
  if (!updated) {
    const error = new Error('未找到对应报名记录')
    error.statusCode = 404
    error.code = 'checkin_registration_not_found'
    throw error
  }
  if (!updateResult) {
    const error = new Error('当前报名状态不可核销')
    error.statusCode = 409
    error.code = 'checkin_status_not_allowed'
    throw error
  }
  await auditCheckin({
    account,
    kind: 'event',
    registration: updated,
    source: payload.source || 'miniapp_manual',
    method: 'manual',
  })
  return { ...updated, alreadyCheckedIn: false }
}

function normalizeCheckinKind(kind) {
  const value = String(kind || 'event').trim()
  if (value === 'course') return 'course'
  if (value === 'all') return 'all'
  return 'event'
}

function checkinProjectStartsAt(item) {
  const raw = item?.startsAt || item?.starts_at || ''
  const date = raw ? new Date(raw) : null
  return date && Number.isFinite(date.getTime()) ? date : null
}

function filterCheckinProjectsByRange(items = [], range = 'all') {
  const normalizedRange = String(range || 'all')
  if (normalizedRange === 'all') return items
  const now = new Date()
  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
  const endOfToday = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000)
  return items.filter((item) => {
    const startsAt = checkinProjectStartsAt(item)
    if (!startsAt) return normalizedRange !== 'today'
    if (normalizedRange === 'today') return startsAt >= startOfToday && startsAt < endOfToday
    if (normalizedRange === 'upcoming') return startsAt >= startOfToday
    return true
  })
}

function decorateCheckinProject(kind, item = {}) {
  const registeredCount = Number(item.registeredCount ?? item.realRegisteredCount ?? 0) || 0
  const checkedInCount = Number(item.checkedInCount || 0) || 0
  return {
    ...item,
    kind,
    productId: item.productId || item.id,
    capacity: Number(item.capacity || 0) || 0,
    registeredCount,
    checkedInCount,
    pendingCount: Math.max(registeredCount - checkedInCount, 0),
  }
}

function decorateCheckinRegistration(kind, item = {}) {
  if (!item) return item
  return {
    ...item,
    kind,
    productKind: kind,
    productId: item.productId || item.event?.productId || item.course?.productId || '',
  }
}

function checkinKeywordMatch(item = {}, keyword = '') {
  if (!keyword) return true
  const haystack = [
    item.userName,
    item.userPhone,
    item.userOrganization,
    item.registrationNo,
    item.orderNo,
    item.checkinCode,
    item.event?.title,
    item.course?.title,
  ].filter(Boolean).join(' ').toLowerCase()
  return haystack.includes(keyword.toLowerCase())
}

function summarizeCheckinRegistrations(items = []) {
  const summary = {
    total: items.length,
    registered: 0,
    checkedIn: 0,
    pendingPayment: 0,
    waitlisted: 0,
    cancelled: 0,
  }
  for (const item of items) {
    if (item.status === 'registered') summary.registered += 1
    if (item.status === 'checked_in') summary.checkedIn += 1
    if (item.status === 'pending_payment') summary.pendingPayment += 1
    if (item.status === 'waitlisted') summary.waitlisted += 1
    if (['cancel_requested', 'cancelled', 'refunded', 'no_show'].includes(item.status)) summary.cancelled += 1
  }
  return summary
}

async function maybeListStaffCheckinItems(listFn, token, fallback = []) {
  try {
    const result = await listFn(token)
    return Array.isArray(result) ? result : fallback
  } catch (error) {
    if (error?.statusCode === 403) return fallback
    throw error
  }
}

export async function listStaffCheckinProjects(token, filters = {}) {
  await requireAdminAny(token, ['event_registration:read', 'event:read', 'course_registration:read'])
  const kind = normalizeCheckinKind(filters.kind)
  const tasks = []
  if (kind === 'all' || kind === 'event') {
    tasks.push(maybeListStaffCheckinItems(listStaffEventProducts, token).then((items) => items.map((item) => decorateCheckinProject('event', item))))
  }
  if (kind === 'all' || kind === 'course') {
    tasks.push(maybeListStaffCheckinItems(listStaffCourseProducts, token).then((items) => items.map((item) => decorateCheckinProject('course', item))))
  }
  const items = (await Promise.all(tasks)).flat()
  return filterCheckinProjectsByRange(items, filters.range || 'all')
}

export async function listStaffCheckinRegistrations(token, filters = {}) {
  const kind = normalizeCheckinKind(filters.kind)
  if (kind === 'all') {
    const error = new Error('请选择活动或课程')
    error.statusCode = 400
    error.code = 'missing_checkin_kind'
    throw error
  }
  const productId = String(filters.productId || filters.eventId || filters.courseId || '').trim()
  if (!productId) {
    const error = new Error('请选择核销项目')
    error.statusCode = 400
    error.code = 'missing_checkin_project'
    throw error
  }
  const status = String(filters.status || '').trim()
  const keyword = String(filters.keyword || filters.search || '').trim()
  const page = Math.max(1, toSafeInteger(filters.page, 1))
  const pageSize = Math.min(1000, Math.max(1, toSafeInteger(filters.pageSize ?? filters.page_size, 50)))
  const allItems = kind === 'course'
    ? await listStaffCourseRegistrations(token, { productId, status, includeCancelled: !status })
    : await listStaffEventRegistrations(token, { productId, status, includeCancelled: !status })
  const filtered = allItems
    .map((item) => decorateCheckinRegistration(kind, item))
    .filter((item) => checkinKeywordMatch(item, keyword))
  const start = (page - 1) * pageSize
  return {
    items: filtered.slice(start, start + pageSize),
    summary: summarizeCheckinRegistrations(filtered),
    page,
    pageSize,
    total: filtered.length,
  }
}

export async function checkInStaffRegistrationByCode(token, payload = {}) {
  const kind = normalizeCheckinKind(payload.kind)
  if (kind === 'all') {
    const error = new Error('请选择活动或课程')
    error.statusCode = 400
    error.code = 'missing_checkin_kind'
    throw error
  }
  const result = kind === 'course'
    ? await checkInCourseRegistrationByCode(token, { ...payload, courseId: payload.productId || payload.courseId })
    : await checkInEventRegistrationByCode(token, { ...payload, eventId: payload.productId || payload.eventId })
  return {
    registration: decorateCheckinRegistration(kind, result),
    alreadyCheckedIn: Boolean(result?.alreadyCheckedIn),
  }
}

export async function checkInStaffRegistrationById(token, payload = {}) {
  const kind = normalizeCheckinKind(payload.kind)
  if (kind === 'all') {
    const error = new Error('请选择活动或课程')
    error.statusCode = 400
    error.code = 'missing_checkin_kind'
    throw error
  }
  const registrationId = String(payload.registrationId || payload.id || '').trim()
  if (!registrationId) {
    const error = new Error('报名记录不能为空')
    error.statusCode = 400
    error.code = 'missing_registration_id'
    throw error
  }
  const result = kind === 'course'
    ? await checkInCourseRegistrationById(token, registrationId, { ...payload, courseId: payload.productId || payload.courseId })
    : await checkInEventRegistrationById(token, registrationId, { ...payload, eventId: payload.productId || payload.eventId })
  return {
    registration: decorateCheckinRegistration(kind, result),
    alreadyCheckedIn: Boolean(result?.alreadyCheckedIn),
  }
}

export async function createStaffCheckinRegistration(token, payload = {}) {
  const kind = normalizeCheckinKind(payload.kind)
  if (kind === 'all') {
    const error = new Error('请选择活动或课程')
    error.statusCode = 400
    error.code = 'missing_checkin_kind'
    throw error
  }
  const productId = String(payload.productId || payload.eventId || payload.courseId || '').trim()
  if (!productId) {
    const error = new Error('请选择核销项目')
    error.statusCode = 400
    error.code = 'missing_checkin_project'
    throw error
  }
  const result = kind === 'course'
    ? await createStaffCourseRegistration(token, productId, { ...payload, courseId: productId })
    : await createStaffEventRegistration(token, productId, { ...payload, eventId: productId })
  return {
    registration: decorateCheckinRegistration(kind, result),
    alreadyRegistered: Boolean(result?.alreadyRegistered),
    alreadyCheckedIn: Boolean(result?.alreadyCheckedIn),
  }
}

async function staffWorkbenchCheckinCounts(token) {
  try {
    const todayProjects = await listStaffCheckinProjects(token, { kind: 'all', range: 'today' })
    return todayProjects.reduce((acc, item) => ({
      todayProjects: acc.todayProjects + 1,
      pending: acc.pending + Number(item.pendingCount || 0),
      checkedIn: acc.checkedIn + Number(item.checkedInCount || 0),
    }), { todayProjects: 0, pending: 0, checkedIn: 0 })
  } catch (error) {
    if (error?.statusCode === 403) return { todayProjects: 0, pending: 0, checkedIn: 0 }
    throw error
  }
}

async function staffWorkbenchListCount(loader) {
  try {
    const result = await loader()
    const items = Array.isArray(result) ? result : result?.items
    return Array.isArray(items) ? items.length : 0
  } catch (error) {
    if (error?.statusCode === 403) return 0
    throw error
  }
}

export async function getStaffWorkbench(token) {
  const { account, permissions } = await requireAdmin(token, 'staff:me')
  const modules = []
  if (staffHasPermission(account, 'event_checkin:update', permissions) || staffHasPermission(account, 'course_checkin:update', permissions) || staffHasPermission(account, 'event:checkin', permissions)) {
    modules.push({
      key: 'checkin',
      title: '核销工作台',
      entry: '/pages/checkin/events/index',
      permissions: permissions.filter((permission) => ['event:checkin', 'event_checkin:update', 'course_checkin:update', 'event_registration:read', 'course_registration:read'].includes(permission)),
      counts: await staffWorkbenchCheckinCounts(token),
    })
  }
  if (staffHasPermission(account, 'task:read', permissions)) {
    modules.push({
      key: 'tasks',
      title: '交付任务',
      entry: '/pages/staff/tasks/index',
      permissions: permissions.filter((permission) => permission.startsWith('task:')),
      counts: {
        pool: await staffWorkbenchListCount(() => listStaffTasks(token, { scope: 'pool' })),
        mine: await staffWorkbenchListCount(() => listStaffTasks(token, { scope: 'mine' })),
      },
    })
  }
  if (staffHasPermission(account, 'consulting:read', permissions)) {
    modules.push({
      key: 'consulting',
      title: '咨询服务单',
      entry: '/pages/staff/consulting/index',
      permissions: permissions.filter((permission) => permission.startsWith('consulting:')),
      counts: {
        pool: await staffWorkbenchListCount(() => listStaffConsultingRequests(token, { scope: 'pool' })),
        mine: await staffWorkbenchListCount(() => listStaffConsultingRequests(token, { scope: 'mine' })),
      },
    })
  }
  if (staffHasPermission(account, 'data_service:read', permissions)) {
    modules.push({
      key: 'data-service',
      title: '数据服务单',
      entry: '/pages/staff/data-services/index',
      permissions: permissions.filter((permission) => permission.startsWith('data_service:')),
      counts: {
        pool: await staffWorkbenchListCount(() => listStaffDataServiceRequests(token, { scope: 'pool' })),
        mine: await staffWorkbenchListCount(() => listStaffDataServiceRequests(token, { scope: 'mine' })),
      },
    })
  }
  if (staffHasPermission(account, 'order:read', permissions)) {
    modules.push({
      key: 'orders',
      title: '订单只读',
      entry: '/pages/staff/orders/index',
      permissions: permissions.filter((permission) => permission === 'order:read'),
      counts: {
        paid: await staffWorkbenchListCount(() => listAdminOrders(token, { status: 'paid' })),
      },
    })
  }
  const preferred = {
    checkin_staff: 'checkin',
    agent_staff: 'tasks',
    consultant: 'consulting',
    data_service_staff: 'data-service',
  }[account.role]
  return {
    account,
    defaultModule: modules.some((item) => item.key === preferred) ? preferred : (modules[0]?.key || ''),
    modules,
  }
}

export async function updateAdminEventRegistration(token, registrationId, payload = {}) {
  const { actor } = await requireAdminAny(token, ['event_registration:update', 'event:write', 'product:write'])
  const current = await queryJson(`
    select ${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')}
    from event_registrations er
    join users u on u.id = er.user_id
    join products p on p.id = er.product_id and p.type = 'event'
    join event_product_details d on d.product_id = p.id
    left join orders o on o.id = er.order_id
    left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
    where ${registrationWhere(registrationId, 'er')}
    limit 1;
  `)
  if (!current) {
    const error = new Error('活动报名记录不存在')
    error.statusCode = 404
    error.code = 'event_registration_not_found'
    throw error
  }
  const allowedStatuses = new Set(['registered', 'cancelled'])
  const nextStatus = payload.status && allowedStatuses.has(payload.status) ? payload.status : current.status
  if (current.status === 'checked_in' && nextStatus === 'cancelled') {
    const error = new Error('已核销报名不能直接取消')
    error.statusCode = 409
    error.code = 'checked_in_registration_cannot_cancel'
    throw error
  }
  await executeSql(`
    update event_registrations
    set status = ${sqlLiteral(nextStatus)},
        checkin_note = coalesce(${sqlLiteral(payload.note || payload.checkinNote || '')}, checkin_note),
        updated_at = now()
    where id = ${sqlLiteral(current.id)}::uuid;
  `)
  const updated = await queryJson(`
    select ${eventRegistrationJson('er', 'u', 'p', 'd', 'o', 'sa')}
    from event_registrations er
    join users u on u.id = er.user_id
    join products p on p.id = er.product_id and p.type = 'event'
    join event_product_details d on d.product_id = p.id
    left join orders o on o.id = er.order_id
    left join staff_accounts sa on sa.id = er.checked_in_by_staff_id
    where er.id = ${sqlLiteral(current.id)}::uuid
    limit 1;
  `)
  await auditAdmin(actor, 'event_registration.update', 'event_registration', updated.id, { before: current, after: updated })
  return updated
}

export async function cancelAdminEventRegistration(token, registrationId, payload = {}) {
  return updateAdminEventRegistration(token, registrationId, {
    ...payload,
    status: 'cancelled',
  })
}

export async function listAccountEntitlements(token) {
  const { user } = await requireUser(token)
  return queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', e.id,
      'userId', e.user_id,
      'productId', e.product_id,
      'orderId', e.order_id,
      'entitlementType', e.entitlement_type,
      'status', e.status,
      'validFrom', e.valid_from,
      'validTo', e.valid_to,
      'remainingUses', e.remaining_uses,
      'createdAt', e.created_at
    ) order by e.created_at desc), '[]'::json)
    from entitlements e
    where e.user_id = ${sqlLiteral(user.id)}::uuid;
  `)
}

export async function listAccountTasks(token, filters = {}) {
  const { user } = await requireUser(token)
  return queryJson(`
    select coalesce(json_agg(task_json order by (task_json->>'createdAt') desc), '[]'::json)
    from (
      select ${fulfillmentTaskJson('ft', 'o', 'u', 'p', 'sa')} as task_json
      from fulfillment_tasks ft
      join orders o on o.id = ft.order_id
      join users u on u.id = o.user_id
      join products p on p.id = ft.product_id
      left join staff_accounts sa on sa.id = ft.assignee_account_id
      where o.user_id = ${sqlLiteral(user.id)}::uuid
        and (${sqlLiteral(filters.status || '')} = '' or ft.status = ${sqlLiteral(filters.status || '')})
        and (${sqlLiteral(filters.type || '')} = '' or ft.type = ${sqlLiteral(filters.type || '')})
    ) t;
  `)
}

export async function getAccountTask(token, taskId) {
  const { user } = await requireUser(token)
  return queryJson(`
    select ${fulfillmentTaskJson('ft', 'o', 'u', 'p', 'sa')}
    from fulfillment_tasks ft
    join orders o on o.id = ft.order_id
    join users u on u.id = o.user_id
    join products p on p.id = ft.product_id
    left join staff_accounts sa on sa.id = ft.assignee_account_id
    where ${fulfillmentTaskWhere(taskId, 'ft')}
      and o.user_id = ${sqlLiteral(user.id)}::uuid
    limit 1;
  `)
}

export async function submitAccountTaskMaterials(token, taskId, payload = {}) {
  const { user } = await requireUser(token)
  const fileIds = Array.isArray(payload.fileIds)
    ? payload.fileIds.map((id) => String(id || '').trim()).filter(Boolean)
    : Array.isArray(payload.file_ids)
      ? payload.file_ids.map((id) => String(id || '').trim()).filter(Boolean)
      : []
  const materialPayload = {
    organization: String(payload.organization || '').trim(),
    contact: String(payload.contact || '').trim(),
    background: String(payload.background || '').trim(),
    fileIds,
    files: Array.isArray(payload.files) ? payload.files : [],
  }
  const noteParts = [
    materialPayload.organization ? `机构：${materialPayload.organization}` : '',
    materialPayload.contact ? `联系人/电话：${materialPayload.contact}` : '',
    materialPayload.background ? `补充说明：${materialPayload.background}` : '',
  ].filter(Boolean)
  const progressNote = noteParts.join('\n') || payload.progressNote || '用户已提交补充资料'

  const updated = await queryJson(`
    update fulfillment_tasks ft
    set
      status = case
        when ft.status = 'pending_material' then 'assigned'
        else ft.status
      end,
      progress_note = ${sqlLiteral(progressNote)},
      material_payload = ${sqlLiteral(JSON.stringify(materialPayload))}::jsonb,
      updated_at = now()
    from orders o
    where ft.order_id = o.id
      and ${fulfillmentTaskWhere(taskId, 'ft')}
      and o.user_id = ${sqlLiteral(user.id)}::uuid
    returning json_build_object(
      'id', ft.id,
      'taskNo', ft.task_no,
      'orderId', ft.order_id,
      'orderNo', o.order_no,
      'userPhone', ${sqlLiteral(user.phone)},
      'productId', ft.product_id,
      'productTitle', (select p.title from products p where p.id = ft.product_id),
      'type', ft.type,
      'title', ft.title,
      'status', ft.status,
      'priority', ft.priority,
      'assigneeAccountId', ft.assignee_account_id,
      'assigneeName', (
        select sa.display_name from staff_accounts sa where sa.id = ft.assignee_account_id
      ),
      'acceptedAt', ft.accepted_at,
      'progressNote', ft.progress_note,
      'resultSummary', ft.result_summary,
      'materialPayload', coalesce(ft.material_payload, '{}'::jsonb),
      'completedAt', ft.completed_at,
      'createdAt', ft.created_at,
      'updatedAt', ft.updated_at
    );
  `)

  if (!updated) {
    const error = new Error('交付任务不存在')
    error.statusCode = 404
    error.code = 'task_not_found'
    throw error
  }

  return updated
}

export async function createConsultingRequest(token, payload = {}) {
  const { user } = await requireUser(token)
  const request = normalizeConsultingPayload(payload, user)
  const directionCheck = await queryJson(`
    select json_build_object(
      'total', count(*),
      'matched', count(*) filter (where title = ${sqlLiteral(request.direction)}),
      'ownerDepartment', coalesce(max(owner_department) filter (where title = ${sqlLiteral(request.direction)}), ''),
      'defaultAssigneeId', max(default_assignee_id::text) filter (where title = ${sqlLiteral(request.direction)})
    )
    from site_consulting_directions
    where status = 'published';
  `)
  if (Number(directionCheck?.total || 0) > 0 && Number(directionCheck?.matched || 0) === 0) {
    const error = new Error('咨询方向不存在或已隐藏')
    error.statusCode = 400
    error.code = 'invalid_consulting_direction'
    throw error
  }
  const requestNo = await nextBusinessNo('CON')

  return queryJson(`
    insert into consulting_requests (
      request_no, user_id, status, current_step, company, contact_name,
      contact_phone, email, direction, organization_type, industry, city,
      project_stage, urgency, budget_range, expected_start, current_situation,
      requirement, goals, preferred_contact_time, owner_department, assigned_account_id
    )
    values (
      ${sqlLiteral(requestNo)},
      ${sqlLiteral(user.id)}::uuid,
      'pending',
      0,
      ${sqlLiteral(request.company)},
      ${sqlLiteral(request.contactName)},
      ${sqlLiteral(request.contactPhone)},
      ${sqlLiteral(request.email)},
      ${sqlLiteral(request.direction)},
      ${sqlLiteral(request.organizationType)},
      ${sqlLiteral(request.industry)},
      ${sqlLiteral(request.city)},
      ${sqlLiteral(request.projectStage)},
      ${sqlLiteral(request.urgency)},
      ${sqlLiteral(request.budgetRange)},
      ${sqlLiteral(request.expectedStart)},
      ${sqlLiteral(request.currentSituation)},
      ${sqlLiteral(request.requirement)},
      ${sqlLiteral(request.goals)},
      ${sqlLiteral(request.preferredContactTime)},
      ${sqlLiteral(directionCheck?.ownerDepartment || consultingOwner(request.direction))},
      ${directionCheck?.defaultAssigneeId ? `${sqlLiteral(directionCheck.defaultAssigneeId)}::uuid` : 'null'}
    )
    returning ${consultingRequestJson('consulting_requests')};
  `)
}

export async function listAccountConsultingRequests(token) {
  const { user } = await requireUser(token)
  const items = await queryJson(`
    select coalesce(json_agg(${consultingRequestJson('cr')} order by cr.created_at desc), '[]'::json)
    from consulting_requests cr
    where cr.user_id = ${sqlLiteral(user.id)}::uuid;
  `)
  return Array.isArray(items) ? items : []
}

export async function listStaffConsultingRequests(token, filters = {}) {
  const { account, permissions } = await requireAdmin(token, 'consulting:read')
  const keyword = String(filters.keyword || filters.search || '').trim()
  const scopeWhere = staffScopeCondition('cr.assigned_account_id', account, filters, permissions)
  return queryJson(`
    select coalesce(json_agg(request_json order by (request_json->>'createdAt') desc), '[]'::json)
    from (
      select (${consultingRequestJson('cr')})::jsonb || jsonb_build_object(
        'userPhone', u.phone,
        'assigneeName', sa.display_name
      ) as request_json
      from consulting_requests cr
      join users u on u.id = cr.user_id
      left join staff_accounts sa on sa.id = cr.assigned_account_id
      where (${sqlLiteral(filters.status || '')} = '' or cr.status = ${sqlLiteral(filters.status || '')})
        and (${scopeWhere})
        and (${sqlLiteral(keyword)} = '' or (
          cr.request_no ilike '%' || ${sqlLiteral(keyword)} || '%'
          or cr.company ilike '%' || ${sqlLiteral(keyword)} || '%'
          or cr.contact_name ilike '%' || ${sqlLiteral(keyword)} || '%'
          or cr.contact_phone ilike '%' || ${sqlLiteral(keyword)} || '%'
          or cr.direction ilike '%' || ${sqlLiteral(keyword)} || '%'
          or u.phone ilike '%' || ${sqlLiteral(keyword)} || '%'
        ))
    ) t;
  `)
}

export async function getStaffConsultingRequest(token, requestId) {
  const { account, permissions } = await requireAdmin(token, 'consulting:read')
  const requestWhere = isUuid(requestId)
    ? `cr.id = ${sqlLiteral(requestId)}::uuid`
    : `(cr.request_no = ${sqlLiteral(requestId)} or cr.legacy_request_no = ${sqlLiteral(requestId)})`
  return queryJson(`
    select (${consultingRequestJson('cr')})::jsonb || jsonb_build_object(
      'userPhone', u.phone,
      'assigneeName', sa.display_name
    )
    from consulting_requests cr
    join users u on u.id = cr.user_id
    left join staff_accounts sa on sa.id = cr.assigned_account_id
    where ${requestWhere}
      and ${staffRecordVisible('cr.assigned_account_id', account, permissions)}
    limit 1;
  `)
}

export async function updateStaffConsultingRequest(token, requestId, payload = {}) {
  const { actor, account, permissions } = await requireAdmin(token, 'consulting:update')
  const requestedStatus = payload.status || (payload.accept ? 'assigned' : null)
  if (requestedStatus && !consultingStatuses.has(requestedStatus)) {
    const error = new Error('咨询状态无效')
    error.statusCode = 400
    error.code = 'invalid_consulting_status'
    throw error
  }
  const requestWhere = isUuid(requestId)
    ? `id = ${sqlLiteral(requestId)}::uuid`
    : `(request_no = ${sqlLiteral(requestId)} or legacy_request_no = ${sqlLiteral(requestId)})`

  const currentAssignment = await queryJson(`
    select json_build_object(
      'id', id,
      'status', status,
      'assignedAccountId', assigned_account_id,
      'paidAt', paid_at
    )
    from consulting_requests
    where ${requestWhere}
    limit 1;
  `)
  if (!currentAssignment) {
    const error = new Error('咨询需求不存在')
    error.statusCode = 404
    error.code = 'consulting_not_found'
    throw error
  }
  assertStaffCanTouchAssigned(currentAssignment, account, permissions, 'assignedAccountId')

  const quoteAmount = payload.quotedAmount ?? payload.quoteAmount ?? payload.quoted_amount
  const normalizedQuoteAmount = quoteAmount === undefined || quoteAmount === null || quoteAmount === ''
    ? null
    : toSafeMoney(quoteAmount)
  const membershipDiscountAmount = toSafeMoney(payload.membershipDiscountAmount ?? payload.membership_discount_amount ?? 0)
  const originalQuoteInput = payload.originalQuotedAmount ?? payload.original_quoted_amount
  const originalQuotedAmount = originalQuoteInput === undefined || originalQuoteInput === null || originalQuoteInput === ''
    ? (normalizedQuoteAmount === null ? null : normalizedQuoteAmount + membershipDiscountAmount)
    : toSafeMoney(originalQuoteInput)
  const finalQuotedAmount = originalQuotedAmount === null
    ? normalizedQuoteAmount
    : Math.max(originalQuotedAmount - membershipDiscountAmount, 0)
  const chargeRequired = Boolean(payload.chargeRequired ?? payload.charge_required ?? finalQuotedAmount)
  const quoteRequested = chargeRequired || normalizedQuoteAmount !== null || originalQuotedAmount !== null || payload.quotedTitle || payload.quoteTitle || payload.quoted_note || payload.membershipDiscountNote || payload.membership_discount_note || requestedStatus === 'proposal_ready'
  if (quoteRequested && chargeRequired && (!finalQuotedAmount || finalQuotedAmount <= 0)) {
    const error = new Error('收费规划咨询报价金额必须大于 0')
    error.statusCode = 400
    error.code = 'invalid_consulting_quote'
    throw error
  }
  const finalSummary = String(payload.finalSummary ?? payload.final_summary ?? payload.solutionSummary ?? payload.solution_summary ?? '').trim()
  if (requestedStatus === 'completed' && !finalSummary) {
    const error = new Error('完成规划咨询前需要填写最终方案反馈')
    error.statusCode = 400
    error.code = 'missing_consulting_final_summary'
    throw error
  }

  const accepting = Boolean(payload.accept)
  const assignableAccountId = account?.id && account.id !== 'dev-admin' ? account.id : null
  const nextStatus = quoteRequested
    ? (chargeRequired ? 'proposal_ready' : (requestedStatus || 'planning'))
    : requestedStatus
  const currentStep = consultingStep(nextStatus)
  const finalFileId = isUuid(payload.finalFileId || payload.final_file_id)
    ? String(payload.finalFileId || payload.final_file_id)
    : null
  let membershipLevelSnapshot = String(payload.membershipLevelSnapshot || payload.membership_level_snapshot || '').trim()
  if (quoteRequested && !membershipLevelSnapshot) {
    const requestUser = await queryJson(`
      select json_build_object('userId', user_id)
      from consulting_requests
      where ${requestWhere}
      limit 1;
    `)
    if (requestUser?.userId) {
      const resolved = await resolveUserMembership(requestUser.userId)
      membershipLevelSnapshot = resolved.level
    }
  }

  if (nextStatus === 'in_service') {
    if (currentAssignment && ['proposal_ready', 'pending_payment'].includes(currentAssignment.status) && !currentAssignment.paidAt) {
      const error = new Error('未付款规划咨询不能直接进入服务中')
      error.statusCode = 409
      error.code = 'consulting_payment_required'
      throw error
    }
  }

  const updated = await queryJson(`
    update consulting_requests
    set
      status = coalesce(${sqlLiteral(nextStatus)}, status),
      current_step = coalesce(${currentStep === null ? 'null' : currentStep}, current_step),
      assigned_account_id = case
        when ${(accepting || (nextStatus && assignableAccountId)) && assignableAccountId ? 'true' : 'false'} then ${sqlLiteral(assignableAccountId)}::uuid
        else assigned_account_id
      end,
      accepted_at = case
        when ${accepting ? 'true' : 'false'} and accepted_at is null then now()
        else accepted_at
      end,
      progress_note = coalesce(${sqlLiteral(payload.progressNote ?? payload.progress_note)}, progress_note),
      solution_summary = coalesce(${sqlLiteral(payload.solutionSummary ?? payload.solution_summary)}, solution_summary),
      charge_required = case
        when ${quoteRequested ? 'true' : 'false'} then ${chargeRequired ? 'true' : 'false'}
        else charge_required
      end,
      quoted_title = coalesce(${sqlLiteral(payload.quotedTitle ?? payload.quoteTitle ?? payload.quoted_title)}, quoted_title),
      quoted_amount = case
        when ${finalQuotedAmount === null ? 'false' : 'true'} then ${finalQuotedAmount || 0}
        else quoted_amount
      end,
      original_quoted_amount = case
        when ${originalQuotedAmount === null ? 'false' : 'true'} then ${originalQuotedAmount || 0}
        else original_quoted_amount
      end,
      membership_discount_amount = case
        when ${quoteRequested ? 'true' : 'false'} then ${membershipDiscountAmount}
        else membership_discount_amount
      end,
      membership_discount_note = coalesce(${sqlLiteral(payload.membershipDiscountNote ?? payload.membership_discount_note)}, membership_discount_note),
      membership_level_snapshot = case
        when ${quoteRequested ? 'true' : 'false'} then ${sqlLiteral(membershipLevelSnapshot)}
        else membership_level_snapshot
      end,
      quoted_note = coalesce(${sqlLiteral(payload.quotedNote ?? payload.quoteNote ?? payload.quoted_note)}, quoted_note),
      quoted_at = case
        when ${quoteRequested ? 'true' : 'false'} then now()
        else quoted_at
      end,
      final_summary = coalesce(${sqlLiteral(finalSummary || null)}, final_summary),
      final_file_id = coalesce(${finalFileId ? `${sqlLiteral(finalFileId)}::uuid` : 'null'}, final_file_id),
      internal_note = coalesce(${sqlLiteral(payload.internalNote ?? payload.internal_note)}, internal_note),
      completed_at = case
        when ${sqlLiteral(nextStatus)} = 'completed' and completed_at is null then now()
        else completed_at
      end,
      updated_at = now()
    where ${requestWhere}
    returning ${consultingRequestJson('consulting_requests')};
  `)

  if (!updated) {
    const error = new Error('咨询需求不存在')
    error.statusCode = 404
    error.code = 'consulting_not_found'
    throw error
  }

  if (finalFileId) {
    await markUploadedFilesOwner([finalFileId], {
      ownerType: 'consulting_deliverable',
      ownerId: updated.id,
      visibility: 'private',
      currentOwnerTypes: ['consulting_deliverable', 'data_service_deliverable'],
      currentOwnerId: updated.id,
      allowUnowned: true,
    })
  }

  await auditAdmin(actor, 'consulting.update', 'consulting_request', updated.id, updated)
  if (nextStatus === 'proposal_ready') {
    await queueServiceNotice(updated.userId, 'consulting', 'consulting_quote_ready', {
      title: '规划咨询报价已生成',
      body: `${updated.quotedTitle || updated.direction || '规划咨询'}已生成报价，请确认后付款。`,
      actionUrl: `/account/consulting/${updated.requestNo || updated.id}`,
      requestNo: updated.requestNo,
    }, `consulting_quote_ready:${updated.id}:${updated.quotedAt || Date.now()}`)
  }
  if (nextStatus === 'completed') {
    await queueServiceNotice(updated.userId, 'consulting', 'consulting_completed', {
      title: '规划咨询最终方案已反馈',
      body: `${updated.direction || '规划咨询'}已完成，最终方案反馈和附件已开放查看。`,
      actionUrl: `/account/consulting/${updated.requestNo || updated.id}`,
      requestNo: updated.requestNo,
    }, `consulting_completed:${updated.id}`)
  }
  return getStaffConsultingRequest(token, updated.id)
}

export async function listAdminConsultingRequests(token, filters = {}) {
  const { account } = await requireAdmin(token, 'consulting:read')
  const page = Math.max(1, toSafeInteger(filters.page, 1))
  const pageSize = Math.min(100, Math.max(1, toSafeInteger(filters.pageSize ?? filters.page_size, 20)))
  const offset = (page - 1) * pageSize
  const keyword = String(filters.keyword || '').trim()
  const scope = String(filters.scope || 'all')
  const ownOnly = scope === 'mine' && account?.id && account.id !== 'dev-admin'
  const assigneeId = isUuid(filters.assigneeId || filters.assignee_id)
    ? String(filters.assigneeId || filters.assignee_id)
    : ''
  const department = String(filters.department || filters.ownerDepartment || filters.owner_department || '').trim()
  const items = await queryJson(`
    select coalesce(json_agg(request_json order by (request_json->>'createdAt') desc), '[]'::json)
    from (
      select (${consultingRequestJson('cr')})::jsonb || jsonb_build_object(
        'userPhone', u.phone,
        'assigneeName', sa.display_name
      ) as request_json
      from consulting_requests cr
      join users u on u.id = cr.user_id
      left join staff_accounts sa on sa.id = cr.assigned_account_id
      where (${sqlLiteral(filters.status || '')} = '' or cr.status = ${sqlLiteral(filters.status || '')})
        and (${ownOnly ? `cr.assigned_account_id = ${sqlLiteral(account.id)}::uuid` : 'true'})
        and (${assigneeId ? `cr.assigned_account_id = ${sqlLiteral(assigneeId)}::uuid` : 'true'})
        and (${sqlLiteral(department)} = '' or cr.owner_department = ${sqlLiteral(department)})
        and (${sqlLiteral(keyword)} = '' or (
          cr.request_no ilike '%' || ${sqlLiteral(keyword)} || '%'
          or cr.company ilike '%' || ${sqlLiteral(keyword)} || '%'
          or cr.contact_name ilike '%' || ${sqlLiteral(keyword)} || '%'
          or cr.contact_phone ilike '%' || ${sqlLiteral(keyword)} || '%'
          or cr.direction ilike '%' || ${sqlLiteral(keyword)} || '%'
          or u.phone ilike '%' || ${sqlLiteral(keyword)} || '%'
        ))
      order by cr.created_at desc
      limit ${pageSize} offset ${offset}
    ) t;
  `)
  const summary = await queryJson(`
    select json_build_object(
      'total', count(*)::int,
      'pending', count(*) filter (where status = 'pending')::int,
      'assigned', count(*) filter (where status = 'assigned')::int,
      'communicating', count(*) filter (where status = 'communicating')::int,
      'planning', count(*) filter (where status = 'planning')::int,
      'proposalReady', count(*) filter (where status = 'proposal_ready')::int,
      'pendingPayment', count(*) filter (where status = 'pending_payment')::int,
      'inService', count(*) filter (where status = 'in_service')::int,
      'completed', count(*) filter (where status = 'completed')::int,
      'cancelled', count(*) filter (where status = 'cancelled')::int
    )
    from consulting_requests;
  `)
  return {
    items: Array.isArray(items) ? items : [],
    summary: summary || {},
    page,
    pageSize,
    total: Number(summary?.total || 0),
  }
}

export async function getAdminConsultingRequest(token, requestId) {
  return getStaffConsultingRequest(token, requestId)
}

export async function updateAdminConsultingRequest(token, requestId, payload = {}) {
  return updateStaffConsultingRequest(token, requestId, payload)
}

export async function cancelAdminConsultingRequest(token, requestId, payload = {}) {
  const current = await getStaffConsultingRequest(token, requestId)
  if (!current) {
    const error = new Error('咨询需求不存在')
    error.statusCode = 404
    error.code = 'consulting_not_found'
    throw error
  }
  if (current.paidAt || current.status === 'in_service' || current.status === 'completed') {
    const error = new Error('已付款规划咨询不能在普通流程中取消')
    error.statusCode = 409
    error.code = 'consulting_paid_cancel_forbidden'
    throw error
  }
  return updateStaffConsultingRequest(token, requestId, {
    status: 'cancelled',
    internalNote: payload.reason || payload.internalNote || payload.internal_note || '后台取消',
  })
}

function dataServiceRequestWhere(requestId, alias = 'dsr') {
  return isUuid(requestId)
    ? `${alias}.id = ${sqlLiteral(requestId)}::uuid`
    : `(${alias}.request_no = ${sqlLiteral(requestId)} or ${alias}.legacy_request_no = ${sqlLiteral(requestId)})`
}

function fulfillmentTaskWhere(taskId, alias = 'ft') {
  return isUuid(taskId)
    ? `${alias}.id = ${sqlLiteral(taskId)}::uuid`
    : `${alias}.task_no = ${sqlLiteral(taskId)}`
}

function registrationWhere(registrationId, alias) {
  return isUuid(registrationId)
    ? `${alias}.id = ${sqlLiteral(registrationId)}::uuid`
    : `${alias}.registration_no = ${sqlLiteral(registrationId)}`
}

export async function createDataServiceRequest(token, payload = {}) {
  const { user } = await requireUser(token)
  const request = normalizeDataServicePayload(payload, user)
  const requestNo = await nextBusinessNo('DSR')

  return queryJson(`
    insert into data_service_requests (
      request_no, user_id, status, current_step, company, organization_type,
      contact_name, contact_phone, email, city, data_topic, service_type,
      region_scope, time_range, industry_scene, expected_format, data_volume,
      usage_purpose, requirement, expected_result, deadline, budget_range,
      sensitive_data, preferred_contact_time, extra_note
    )
    values (
      ${sqlLiteral(requestNo)},
      ${sqlLiteral(user.id)}::uuid,
      'pending_contact',
      1,
      ${sqlLiteral(request.company)},
      ${sqlLiteral(request.organizationType)},
      ${sqlLiteral(request.contactName)},
      ${sqlLiteral(request.contactPhone)},
      ${sqlLiteral(request.email)},
      ${sqlLiteral(request.city)},
      ${sqlLiteral(request.dataTopic)},
      ${sqlLiteral(request.serviceType)},
      ${sqlLiteral(request.regionScope)},
      ${sqlLiteral(request.timeRange)},
      ${sqlLiteral(request.industryScene)},
      ${sqlLiteral(request.expectedFormat)},
      ${sqlLiteral(request.dataVolume)},
      ${sqlLiteral(request.usagePurpose)},
      ${sqlLiteral(request.requirement)},
      ${sqlLiteral(request.expectedResult)},
      ${sqlLiteral(request.deadline)},
      ${sqlLiteral(request.budgetRange)},
      ${request.sensitiveData ? 'true' : 'false'},
      ${sqlLiteral(request.preferredContactTime)},
      ${sqlLiteral(request.extraNote)}
    )
    returning ${dataServiceRequestJson('data_service_requests')};
  `)
}

export async function listAccountDataServiceRequests(token) {
  const { user } = await requireUser(token)
  return (await queryJson(`
    select coalesce(json_agg(request_json order by (request_json->>'createdAt') desc), '[]'::json)
    from (
      select ${dataServiceRequestJson('dsr', 'u', 'sa', 'o', 'f')} as request_json
      from data_service_requests dsr
      join users u on u.id = dsr.user_id
      left join staff_accounts sa on sa.id = dsr.assigned_account_id
      left join orders o on o.id = dsr.order_id
      left join uploaded_files f on f.id = dsr.deliverable_file_id
      where dsr.user_id = ${sqlLiteral(user.id)}::uuid
    ) t;
  `)) || []
}

export async function getAccountDataServiceRequest(token, requestId) {
  const { user } = await requireUser(token)
  return queryJson(`
    select ${dataServiceRequestJson('dsr', 'u', 'sa', 'o', 'f')}
    from data_service_requests dsr
    join users u on u.id = dsr.user_id
    left join staff_accounts sa on sa.id = dsr.assigned_account_id
    left join orders o on o.id = dsr.order_id
    left join uploaded_files f on f.id = dsr.deliverable_file_id
    where ${dataServiceRequestWhere(requestId, 'dsr')}
      and dsr.user_id = ${sqlLiteral(user.id)}::uuid
    limit 1;
  `)
}

export async function createDataServiceCheckout(token, requestId, payload = {}) {
  const request = await getAccountDataServiceRequest(token, requestId)
  if (!request) {
    const error = new Error('数据服务申请不存在')
    error.statusCode = 404
    error.code = 'data_service_not_found'
    throw error
  }
  if (request.orderId) {
    const existingOrder = await getOrderById(request.orderId)
    if (existingOrder) return existingOrder
  }
  if (request.status !== 'quoted') {
    const error = new Error('数据服务申请尚未报价，暂不能付款')
    error.statusCode = 409
    error.code = 'data_service_not_payable'
    throw error
  }
  return createOrder(token, {
    dataServiceRequestId: request.id,
    items: [{ productId: 'data-service-custom', quantity: 1, dataServiceRequestId: request.id }],
    invoiceRequest: payload.invoiceRequest || payload.invoice || payload.invoiceApplication || payload.invoice_application,
  })
}

function dataServiceListWhere(filters = {}, account = null, permissions = null) {
  const status = String(filters.status || '').trim()
  const keyword = String(filters.keyword || filters.search || '').trim()
  const assigneeId = isUuid(filters.assigneeId || filters.assignee_id) ? String(filters.assigneeId || filters.assignee_id) : ''
  const departmentId = isUuid(filters.departmentId || filters.department_id) ? String(filters.departmentId || filters.department_id) : ''
  const dateFrom = String(filters.dateFrom || filters.date_from || '').trim()
  const dateTo = String(filters.dateTo || filters.date_to || '').trim()
  const scopeWhere = staffScopeCondition('dsr.assigned_account_id', account, filters, permissions)
  const departmentOnly = filters.scope === 'department' && departmentId
  return `
    (${sqlLiteral(status)} = '' or dsr.status = ${sqlLiteral(status)})
    and (${scopeWhere})
    and (${assigneeId ? `dsr.assigned_account_id = ${sqlLiteral(assigneeId)}::uuid` : 'true'})
    and (${departmentOnly ? `exists (
      select 1
      from staff_account_assignments saa
      where saa.account_id = dsr.assigned_account_id
        and saa.org_unit_id = ${sqlLiteral(departmentId)}::uuid
        and saa.status = 'active'
    )` : 'true'})
    and (${sqlLiteral(keyword)} = ''
      or dsr.request_no ilike '%' || ${sqlLiteral(keyword)} || '%'
      or dsr.company ilike '%' || ${sqlLiteral(keyword)} || '%'
      or dsr.contact_name ilike '%' || ${sqlLiteral(keyword)} || '%'
      or dsr.contact_phone ilike '%' || ${sqlLiteral(keyword)} || '%'
      or dsr.data_topic ilike '%' || ${sqlLiteral(keyword)} || '%'
      or dsr.service_type ilike '%' || ${sqlLiteral(keyword)} || '%')
    and (${dateFrom ? `dsr.created_at >= ${sqlLiteral(dateFrom)}::date` : 'true'})
    and (${dateTo ? `dsr.created_at < (${sqlLiteral(dateTo)}::date + interval '1 day')` : 'true'})
  `
}

async function listDataServiceRequestsForAdmin(account, filters = {}, permissions = null) {
  const page = Math.max(1, Number(filters.page || 1) || 1)
  const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize || filters.page_size || 50) || 50))
  const offset = (page - 1) * pageSize
  const where = dataServiceListWhere(filters, account, permissions)
  const items = await queryJson(`
    select coalesce(json_agg(request_json order by (request_json->>'createdAt') desc), '[]'::json)
    from (
      select ${dataServiceRequestJson('dsr', 'u', 'sa', 'o', 'f')} as request_json
      from data_service_requests dsr
      join users u on u.id = dsr.user_id
      left join staff_accounts sa on sa.id = dsr.assigned_account_id
      left join orders o on o.id = dsr.order_id
      left join uploaded_files f on f.id = dsr.deliverable_file_id
      where ${where}
      order by dsr.created_at desc
      limit ${pageSize}
      offset ${offset}
    ) t;
  `) || []
  const summary = await queryJson(`
    select json_build_object(
      'total', count(*)::int,
      'pendingContact', count(*) filter (where dsr.status = 'pending_contact')::int,
      'contacting', count(*) filter (where dsr.status = 'contacting')::int,
      'quoted', count(*) filter (where dsr.status = 'quoted')::int,
      'pendingPayment', count(*) filter (where dsr.status = 'pending_payment')::int,
      'inService', count(*) filter (where dsr.status = 'in_service')::int,
      'completed', count(*) filter (where dsr.status = 'completed')::int,
      'cancelled', count(*) filter (where dsr.status = 'cancelled')::int
    )
    from data_service_requests dsr
    where ${where};
  `) || {
    total: 0,
    pendingContact: 0,
    contacting: 0,
    quoted: 0,
    pendingPayment: 0,
    inService: 0,
    completed: 0,
    cancelled: 0,
  }
  return { items, summary, page, pageSize, total: summary.total || 0 }
}

export async function listAdminDataServiceRequests(token, filters = {}) {
  const { account, permissions } = await requireAdmin(token, 'data_service:read')
  return listDataServiceRequestsForAdmin(account, filters, permissions)
}

export async function listStaffDataServiceRequests(token, filters = {}) {
  const { account, permissions } = await requireAdmin(token, 'data_service:read')
  const result = await listDataServiceRequestsForAdmin(account, filters, permissions)
  return result.items
}

export async function getAdminDataServiceRequest(token, requestId) {
  await requireAdmin(token, 'data_service:read')
  return queryJson(`
    select ${dataServiceRequestJson('dsr', 'u', 'sa', 'o', 'f')}
    from data_service_requests dsr
    join users u on u.id = dsr.user_id
    left join staff_accounts sa on sa.id = dsr.assigned_account_id
    left join orders o on o.id = dsr.order_id
    left join uploaded_files f on f.id = dsr.deliverable_file_id
    where ${dataServiceRequestWhere(requestId, 'dsr')}
    limit 1;
  `)
}

export async function getStaffDataServiceRequest(token, requestId) {
  const { account, permissions } = await requireAdmin(token, 'data_service:read')
  return queryJson(`
    select ${dataServiceRequestJson('dsr', 'u', 'sa', 'o', 'f')}
    from data_service_requests dsr
    join users u on u.id = dsr.user_id
    left join staff_accounts sa on sa.id = dsr.assigned_account_id
    left join orders o on o.id = dsr.order_id
    left join uploaded_files f on f.id = dsr.deliverable_file_id
    where ${dataServiceRequestWhere(requestId, 'dsr')}
      and ${staffRecordVisible('dsr.assigned_account_id', account, permissions)}
    limit 1;
  `)
}

export async function updateStaffDataServiceRequest(token, requestId, payload = {}) {
  const { actor, account, permissions } = await requireAdmin(token, 'data_service:update')
  const currentWorkflow = await queryJson(`
    select json_build_object(
      'id', id,
      'userId', user_id,
      'status', status,
      'paidAt', paid_at,
      'orderId', order_id,
      'assignedAccountId', assigned_account_id
    )
    from data_service_requests
    where ${dataServiceRequestWhere(requestId, 'data_service_requests')}
    limit 1;
  `)
  if (!currentWorkflow) {
    const error = new Error('数据服务申请不存在')
    error.statusCode = 404
    error.code = 'data_service_not_found'
    throw error
  }
  assertStaffCanTouchAssigned(currentWorkflow, account, permissions, 'assignedAccountId')
  const requestedStatus = payload.status || (payload.accept ? 'contacting' : null)
  if (requestedStatus && !dataServiceStatuses.has(requestedStatus)) {
    const error = new Error('数据服务状态无效')
    error.statusCode = 400
    error.code = 'invalid_data_service_status'
    throw error
  }
  const quoteAmount = payload.quotedAmount ?? payload.quoteAmount ?? payload.quoted_amount
  const normalizedQuoteAmount = quoteAmount === undefined || quoteAmount === null || quoteAmount === ''
    ? null
    : toSafeMoney(quoteAmount)
  const membershipDiscountAmount = toSafeMoney(payload.membershipDiscountAmount ?? payload.membership_discount_amount ?? 0)
  const originalQuoteInput = payload.originalQuotedAmount ?? payload.original_quoted_amount
  const originalQuotedAmount = originalQuoteInput === undefined || originalQuoteInput === null || originalQuoteInput === ''
    ? (normalizedQuoteAmount === null ? null : normalizedQuoteAmount + membershipDiscountAmount)
    : toSafeMoney(originalQuoteInput)
  const finalQuotedAmount = originalQuotedAmount === null
    ? normalizedQuoteAmount
    : Math.max(originalQuotedAmount - membershipDiscountAmount, 0)
  const quoteRequested = normalizedQuoteAmount !== null || originalQuotedAmount !== null || payload.quotedTitle || payload.quoteTitle || payload.membershipDiscountNote || payload.membership_discount_note || requestedStatus === 'quoted'
  if (quoteRequested && (!finalQuotedAmount || finalQuotedAmount <= 0)) {
    const error = new Error('数据服务报价金额必须大于 0')
    error.statusCode = 400
    error.code = 'invalid_data_service_quote'
    throw error
  }
  const nextStatus = quoteRequested ? 'quoted' : requestedStatus
  if (nextStatus === 'in_service' && !currentWorkflow.paidAt) {
    const error = new Error('数据服务未完成付款，不能直接进入服务中')
    error.statusCode = 409
    error.code = 'data_service_payment_required'
    throw error
  }
  if (nextStatus === 'cancelled' && currentWorkflow.paidAt) {
    const error = new Error('已付款数据服务不能直接取消，请走退款/关闭流程')
    error.statusCode = 409
    error.code = 'data_service_cancel_requires_refund'
    throw error
  }
  const nextStep = dataServiceStep(nextStatus)
  const requestedAssigneeId = isUuid(payload.assignedAccountId || payload.assigned_account_id)
    ? String(payload.assignedAccountId || payload.assigned_account_id)
    : null
  const assignableAccountId = requestedAssigneeId || (account?.id && account.id !== 'dev-admin' ? account.id : null)
  const accepted = Boolean(payload.accept)
  let membershipLevelSnapshot = String(payload.membershipLevelSnapshot || payload.membership_level_snapshot || '').trim()
  if (quoteRequested && !membershipLevelSnapshot && currentWorkflow.userId) {
    const resolved = await resolveUserMembership(currentWorkflow.userId)
    membershipLevelSnapshot = resolved.level
  }
  const deliverableFileId = isUuid(payload.deliverableFileId || payload.deliverable_file_id)
    ? String(payload.deliverableFileId || payload.deliverable_file_id)
    : null
  const deliverableFileIds = Array.isArray(payload.deliverableFileIds || payload.deliverable_file_ids)
    ? (payload.deliverableFileIds || payload.deliverable_file_ids).filter((id) => isUuid(id))
    : []
  const deliverableFileIdsSql = deliverableFileIds.length
    ? sqlUuidArray(deliverableFileIds)
    : deliverableFileId
      ? sqlUuidArray([deliverableFileId])
      : 'deliverable_file_ids'
  if (nextStatus === 'completed') {
    const currentDelivery = await queryJson(`
      select json_build_object(
        'resultSummary', result_summary,
        'deliverableFileId', deliverable_file_id,
        'deliverableFileIds', coalesce(to_json(deliverable_file_ids), '[]'::json)
      )
      from data_service_requests
      where ${dataServiceRequestWhere(requestId, 'data_service_requests')}
      limit 1;
    `)
    const nextResultSummary = String(payload.resultSummary ?? payload.result_summary ?? currentDelivery?.resultSummary ?? '').trim()
    const existingDeliverableIds = Array.isArray(currentDelivery?.deliverableFileIds) ? currentDelivery.deliverableFileIds.filter(Boolean) : []
    const hasDeliverable = Boolean(deliverableFileId || deliverableFileIds.length || currentDelivery?.deliverableFileId || existingDeliverableIds.length)
    if (!nextResultSummary) {
      const error = new Error('完成数据服务前需要填写结果说明')
      error.statusCode = 400
      error.code = 'missing_data_service_result_summary'
      throw error
    }
    if (!hasDeliverable) {
      const error = new Error('完成数据服务前需要上传至少一个交付包')
      error.statusCode = 400
      error.code = 'missing_data_service_deliverable'
      throw error
    }
  }

  const updated = await queryJson(`
    update data_service_requests dsr
    set
      status = coalesce(${sqlLiteral(nextStatus)}, status),
      current_step = coalesce(${nextStep === null ? 'null' : nextStep}, current_step),
      assigned_account_id = case
        when ${(requestedAssigneeId || accepted || (nextStatus && assignableAccountId)) && assignableAccountId ? 'true' : 'false'} then ${sqlLiteral(assignableAccountId)}::uuid
        else assigned_account_id
      end,
      contact_note = coalesce(${sqlLiteral(payload.contactNote ?? payload.contact_note)}, contact_note),
      progress_note = coalesce(${sqlLiteral(payload.progressNote ?? payload.progress_note)}, progress_note),
      quoted_title = coalesce(${sqlLiteral(payload.quotedTitle ?? payload.quoteTitle ?? payload.quoted_title)}, quoted_title),
      quoted_amount = case
        when ${finalQuotedAmount === null ? 'false' : 'true'} then ${finalQuotedAmount || 0}
        else quoted_amount
      end,
      original_quoted_amount = case
        when ${originalQuotedAmount === null ? 'false' : 'true'} then ${originalQuotedAmount || 0}
        else original_quoted_amount
      end,
      membership_discount_amount = case
        when ${quoteRequested ? 'true' : 'false'} then ${membershipDiscountAmount}
        else membership_discount_amount
      end,
      membership_discount_note = coalesce(${sqlLiteral(payload.membershipDiscountNote ?? payload.membership_discount_note)}, membership_discount_note),
      membership_level_snapshot = case
        when ${quoteRequested ? 'true' : 'false'} then ${sqlLiteral(membershipLevelSnapshot)}
        else membership_level_snapshot
      end,
      quoted_note = coalesce(${sqlLiteral(payload.quotedNote ?? payload.quoteNote ?? payload.quoted_note)}, quoted_note),
      solution_summary = coalesce(${sqlLiteral(payload.solutionSummary ?? payload.solution_summary)}, solution_summary),
      quoted_at = case
        when ${quoteRequested ? 'true' : 'false'} then now()
        else quoted_at
      end,
      deliverable_file_id = coalesce(${deliverableFileId ? `${sqlLiteral(deliverableFileId)}::uuid` : 'null'}, deliverable_file_id),
      deliverable_file_ids = case
        when ${deliverableFileIds.length || deliverableFileId ? 'true' : 'false'} then ${deliverableFileIdsSql}
        else deliverable_file_ids
      end,
      result_summary = coalesce(${sqlLiteral(payload.resultSummary ?? payload.result_summary)}, result_summary),
      internal_note = coalesce(${sqlLiteral(payload.internalNote ?? payload.internal_note)}, internal_note),
      completed_at = case
        when ${sqlLiteral(nextStatus)} = 'completed' and completed_at is null then now()
        else completed_at
      end,
      updated_at = now()
    where ${dataServiceRequestWhere(requestId, 'dsr')}
    returning ${dataServiceRequestJson('dsr')};
  `)

  if (!updated) {
    const error = new Error('数据服务申请不存在')
    error.statusCode = 404
    error.code = 'data_service_not_found'
    throw error
  }

  await markUploadedFilesOwner([
    ...(deliverableFileId ? [deliverableFileId] : []),
    ...deliverableFileIds,
  ], {
    ownerType: 'data_service_deliverable',
    ownerId: updated.id,
    visibility: 'private',
    currentOwnerTypes: ['data_service_deliverable'],
    currentOwnerId: updated.id,
    allowUnowned: true,
  })
  await auditAdmin(actor, 'data_service.update', 'data_service_request', updated.id, updated)
  if (nextStatus === 'quoted') {
    await queueServiceNotice(updated.userId, 'data_service', 'data_service_quote_ready', {
      title: '数据服务报价已生成',
      body: `${updated.quotedTitle || updated.dataTopic || '数据服务'}已生成报价，请确认后付款。`,
      actionUrl: `/account/data/${updated.id}`,
      requestNo: updated.requestNo,
    }, `data_service_quote_ready:${updated.id}:${updated.quotedAt || Date.now()}`)
  }
  if (nextStatus === 'completed') {
    await queueServiceNotice(updated.userId, 'data_service', 'data_service_completed', {
      title: '数据服务交付包已开放',
      body: `${updated.dataTopic || '数据服务'}已完成，交付包和结果说明已开放下载。`,
      actionUrl: `/account/data/${updated.id}`,
      requestNo: updated.requestNo,
    }, `data_service_completed:${updated.id}`)
  }
  return getStaffDataServiceRequest(token, updated.id)
}

export async function updateAdminDataServiceRequest(token, requestId, payload = {}) {
  return updateStaffDataServiceRequest(token, requestId, payload)
}

export async function cancelAdminDataServiceRequest(token, requestId, payload = {}) {
  return updateStaffDataServiceRequest(token, requestId, {
    ...payload,
    status: 'cancelled',
  })
}

export async function listStaffTasks(token, filters = {}) {
  const { account, permissions } = await requireAdmin(token, 'task:read')
  const keyword = String(filters.keyword || filters.search || '').trim()
  const scopeWhere = staffScopeCondition('ft.assignee_account_id', account, filters, permissions)
  return queryJson(`
    select coalesce(json_agg(task_json order by (task_json->>'createdAt') desc), '[]'::json)
    from (
      select ${fulfillmentTaskJson('ft', 'o', 'u', 'p', 'sa')} as task_json
      from fulfillment_tasks ft
      join orders o on o.id = ft.order_id
      join users u on u.id = o.user_id
      join products p on p.id = ft.product_id
      left join staff_accounts sa on sa.id = ft.assignee_account_id
      where (${sqlLiteral(filters.status || '')} = '' or ft.status = ${sqlLiteral(filters.status || '')})
        and (${sqlLiteral(filters.type || '')} = '' or ft.type = ${sqlLiteral(filters.type || '')})
        and (${scopeWhere})
        and (${sqlLiteral(keyword)} = '' or (
          ft.task_no ilike '%' || ${sqlLiteral(keyword)} || '%'
          or ft.title ilike '%' || ${sqlLiteral(keyword)} || '%'
          or o.order_no ilike '%' || ${sqlLiteral(keyword)} || '%'
          or u.phone ilike '%' || ${sqlLiteral(keyword)} || '%'
          or p.title ilike '%' || ${sqlLiteral(keyword)} || '%'
        ))
    ) t;
  `)
}

export async function getStaffTask(token, taskId) {
  const { account, permissions } = await requireAdmin(token, 'task:read')
  return queryJson(`
    select ${fulfillmentTaskJson('ft', 'o', 'u', 'p', 'sa')}
    from fulfillment_tasks ft
    join orders o on o.id = ft.order_id
    join users u on u.id = o.user_id
    join products p on p.id = ft.product_id
    left join staff_accounts sa on sa.id = ft.assignee_account_id
    where ${fulfillmentTaskWhere(taskId, 'ft')}
      and ${staffRecordVisible('ft.assignee_account_id', account, permissions)}
    limit 1;
  `)
}

export async function updateStaffTask(token, taskId, payload) {
  const { actor, account } = await requireAdmin(token, 'task:update')
  if (payload.status && !taskStatuses.has(payload.status)) {
    const error = new Error('任务状态无效')
    error.statusCode = 400
    error.code = 'invalid_task_status'
    throw error
  }
  const current = await getStaffTask(token, taskId)
  if (!current) {
    const error = new Error('交付任务不存在')
    error.statusCode = 404
    error.code = 'task_not_found'
    throw error
  }
  const accepting = Boolean(payload.accept)
  const assignableAccountId = account?.id && account.id !== 'dev-admin' ? account.id : null
  const nextStatus = payload.status || (accepting ? 'assigned' : null)
  const allowedTransitions = {
    pending_material: new Set(['pending_material']),
    assigned: new Set(['assigned', 'in_progress', 'cancelled']),
    in_progress: new Set(['in_progress', 'pending_confirm', 'cancelled']),
    pending_confirm: new Set(['pending_confirm', 'in_progress', 'completed', 'cancelled']),
    completed: new Set(['completed']),
    cancelled: new Set(['cancelled']),
  }
  if (nextStatus && !(allowedTransitions[current.status] || new Set()).has(nextStatus)) {
    const error = new Error('当前交付任务状态不能执行该操作')
    error.statusCode = 409
    error.code = 'invalid_task_transition'
    throw error
  }
  const nextResultSummary = String(payload.resultSummary ?? payload.result_summary ?? current.resultSummary ?? '').trim()
  if (nextStatus === 'completed' && !nextResultSummary) {
    const error = new Error('完成交付任务前需要填写交付结果')
    error.statusCode = 400
    error.code = 'missing_task_result_summary'
    throw error
  }

  const updated = await queryJson(`
    update fulfillment_tasks
    set
      status = coalesce(${sqlLiteral(nextStatus)}, status),
      priority = coalesce(${sqlLiteral(payload.priority)}, priority),
      assignee_account_id = case
        when ${accepting && assignableAccountId ? 'true' : 'false'} then ${sqlLiteral(assignableAccountId)}::uuid
        else assignee_account_id
      end,
      accepted_at = case
        when ${accepting ? 'true' : 'false'} and accepted_at is null then now()
        else accepted_at
      end,
      progress_note = coalesce(${sqlLiteral(payload.progressNote ?? payload.progress_note)}, progress_note),
      result_summary = coalesce(${sqlLiteral(payload.resultSummary ?? payload.result_summary)}, result_summary),
      completed_at = case
        when ${sqlLiteral(nextStatus)} = 'completed' and completed_at is null then now()
        else completed_at
      end,
      updated_at = now()
    where ${fulfillmentTaskWhere(taskId, 'fulfillment_tasks')}
    returning json_build_object(
      'id', id,
      'orderId', order_id,
      'productId', product_id,
      'type', type,
      'title', title,
      'status', status,
      'priority', priority,
      'assigneeAccountId', assignee_account_id,
      'acceptedAt', accepted_at,
      'progressNote', progress_note,
      'resultSummary', result_summary,
      'completedAt', completed_at,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `)

  if (!updated) {
    const error = new Error('任务不存在')
    error.statusCode = 404
    error.code = 'task_not_found'
    throw error
  }

  await auditAdmin(actor, 'task.update', 'fulfillment_task', updated.id, updated)
  return getStaffTask(token, updated.id)
}

async function normalizeCheckoutItems(items = [], user = null) {
  if (!Array.isArray(items) || items.length === 0) {
    const error = new Error('订单至少需要一个商品')
    error.statusCode = 400
    error.code = 'empty_items'
    throw error
  }

  const normalized = []
  for (const item of items) {
    const product = await getProduct(item.productId || item.slug)
    if (!product) {
      const error = new Error('商品不存在或已下架')
      error.statusCode = 400
      error.code = 'invalid_product'
      throw error
    }
    const quantity = Math.max(1, Number(item.quantity || 1))
    const dataServiceCheckout = product.id === 'data-service-custom'
      ? await dataServiceCheckoutInfo(item, user)
      : null
    const consultingCheckout = product.id === 'consulting-service-custom'
      ? await consultingCheckoutInfo(item, user)
      : null
    if (product.type === 'data' && !dataServiceCheckout) {
      const error = new Error('数据产品已下线购买，请提交数据服务需求后按报价结算')
      error.statusCode = 409
      error.code = 'data_product_purchase_disabled'
      throw error
    }
    const billingCycle = product.type === 'membership'
      ? normalizeMembershipBillingCycle(item.billingCycle || item.billing_cycle || 'monthly')
      : 'once'
    const membershipQuote = product.type === 'membership'
      ? await membershipCheckoutQuote(product.id, billingCycle, user)
      : null
    const courseQuote = product.type === 'course' ? await courseCheckoutQuote(product.id, user) : null
    const agentQuote = product.type === 'agent' ? await agentCheckoutQuote(product.id, user) : null
    const eventQuote = product.type === 'event' ? await eventCheckoutQuote(product.id, user) : null
    const expertQuote = product.type === 'expert' ? await expertCheckoutQuote(item, user) : null
    const unitPriceAmount = dataServiceCheckout
      ? dataServiceCheckout.quotedAmount
      : consultingCheckout
        ? consultingCheckout.quotedAmount
      : membershipQuote
        ? membershipQuote.unitPriceAmount
      : courseQuote
        ? courseQuote.unitPriceAmount
      : agentQuote
        ? agentQuote.unitPriceAmount
      : eventQuote
        ? eventQuote.unitPriceAmount
      : expertQuote
        ? expertQuote.unitPriceAmount
      : product.priceAmount
    const listPriceAmount = dataServiceCheckout
      ? dataServiceCheckout.originalQuotedAmount || dataServiceCheckout.quotedAmount
      : consultingCheckout
        ? consultingCheckout.originalQuotedAmount || consultingCheckout.quotedAmount
      : membershipQuote?.listPriceAmount ?? courseQuote?.listPriceAmount ?? agentQuote?.listPriceAmount ?? eventQuote?.listPriceAmount ?? expertQuote?.listPriceAmount ?? product.priceAmount
    const memberPriceAmount = courseQuote?.memberPriceAmount ?? agentQuote?.memberPriceAmount ?? eventQuote?.memberPriceAmount ?? expertQuote?.memberPriceAmount ?? membershipQuote?.memberPriceAmount ?? null
    const memberDiscountAmount = Math.max(0, listPriceAmount - unitPriceAmount)
    const pricingSource = dataServiceCheckout || consultingCheckout
      ? 'manual_quote'
      : membershipQuote?.pricingSource || courseQuote?.pricingSource || agentQuote?.pricingSource || eventQuote?.pricingSource || expertQuote?.pricingSource || 'list_price'
    normalized.push({
      productId: product.id,
      productType: product.type,
      titleSnapshot: dataServiceCheckout?.quotedTitle || consultingCheckout?.quotedTitle || (product.type === 'expert' && item.expertName ? `${item.expertName}专家咨询 1 小时` : product.title),
      unitPriceAmount,
      listPriceAmount,
      memberPriceAmount,
      memberDiscountAmount,
      quantity,
      payableAmount: unitPriceAmount * quantity,
      billingCycle,
      memberDiscountEligible: product.type !== 'membership' && product.type !== 'expert' && !productUsesMemberPrice(product.type) && !dataServiceCheckout && !consultingCheckout,
      accessState: courseQuote?.accessState || agentQuote?.accessState || eventQuote?.accessState || null,
      pricingSource,
      membershipChange: membershipQuote?.membershipChange || null,
      membershipDiscountNote: dataServiceCheckout?.membershipDiscountNote || consultingCheckout?.membershipDiscountNote || '',
      membershipLevelSnapshot: dataServiceCheckout?.membershipLevelSnapshot || consultingCheckout?.membershipLevelSnapshot || '',
      expertId: item.expertId || item.expert_id || null,
      expertBookingId: item.expertBookingId || item.expert_booking_id || null,
      dataServiceRequestId: dataServiceCheckout?.id || null,
      consultingRequestId: consultingCheckout?.id || null,
    })
  }
  return normalized
}

async function consultingCheckoutInfo(item = {}, user) {
  const requestId = item.consultingRequestId || item.consulting_request_id
  if (!isUuid(requestId) || !user?.id) {
    const error = new Error('规划咨询结算缺少有效申请单')
    error.statusCode = 400
    error.code = 'invalid_consulting_checkout'
    throw error
  }
  const request = await queryJson(`
    select json_build_object(
      'id', id,
      'status', status,
      'orderId', order_id,
      'quotedTitle', quoted_title,
      'quotedAmount', quoted_amount,
      'originalQuotedAmount', original_quoted_amount,
      'membershipDiscountAmount', membership_discount_amount,
      'membershipDiscountNote', membership_discount_note,
      'membershipLevelSnapshot', membership_level_snapshot,
      'direction', direction
    )
    from consulting_requests
    where id = ${sqlLiteral(requestId)}::uuid
      and user_id = ${sqlLiteral(user.id)}::uuid
      and status = 'proposal_ready'
      and charge_required = true
      and order_id is null
    limit 1;
  `)
  if (!request) {
    const error = new Error('规划咨询申请不存在、已结算或尚未生成报价')
    error.statusCode = 409
    error.code = 'consulting_not_payable'
    throw error
  }
  if (!Number.isInteger(Number(request.quotedAmount)) || Number(request.quotedAmount) <= 0) {
    const error = new Error('规划咨询报价金额无效')
    error.statusCode = 409
    error.code = 'invalid_consulting_quote'
    throw error
  }
  return {
    id: request.id,
    quotedTitle: request.quotedTitle || `${request.direction || '规划咨询'}服务`,
    quotedAmount: Number(request.quotedAmount),
    originalQuotedAmount: Number(request.originalQuotedAmount || request.quotedAmount || 0),
    membershipDiscountAmount: Number(request.membershipDiscountAmount || 0),
    membershipDiscountNote: request.membershipDiscountNote || '',
    membershipLevelSnapshot: request.membershipLevelSnapshot || '',
  }
}

async function dataServiceCheckoutInfo(item = {}, user) {
  const requestId = item.dataServiceRequestId || item.data_service_request_id
  if (!isUuid(requestId) || !user?.id) {
    const error = new Error('数据服务结算缺少有效申请单')
    error.statusCode = 400
    error.code = 'invalid_data_service_checkout'
    throw error
  }
  const request = await queryJson(`
    select json_build_object(
      'id', id,
      'status', status,
      'orderId', order_id,
      'quotedTitle', quoted_title,
      'quotedAmount', quoted_amount,
      'originalQuotedAmount', original_quoted_amount,
      'membershipDiscountAmount', membership_discount_amount,
      'membershipDiscountNote', membership_discount_note,
      'membershipLevelSnapshot', membership_level_snapshot,
      'dataTopic', data_topic,
      'serviceType', service_type
    )
    from data_service_requests
    where id = ${sqlLiteral(requestId)}::uuid
      and user_id = ${sqlLiteral(user.id)}::uuid
      and status = 'quoted'
      and order_id is null
    limit 1;
  `)
  if (!request) {
    const error = new Error('数据服务申请不存在、已结算或尚未报价')
    error.statusCode = 409
    error.code = 'data_service_not_payable'
    throw error
  }
  if (!Number.isInteger(Number(request.quotedAmount)) || Number(request.quotedAmount) <= 0) {
    const error = new Error('数据服务报价金额无效')
    error.statusCode = 409
    error.code = 'invalid_data_service_quote'
    throw error
  }
  return {
    id: request.id,
    quotedTitle: request.quotedTitle || `${request.dataTopic || '定制数据'}服务包`,
    quotedAmount: Number(request.quotedAmount),
    originalQuotedAmount: Number(request.originalQuotedAmount || request.quotedAmount || 0),
    membershipDiscountAmount: Number(request.membershipDiscountAmount || 0),
    membershipDiscountNote: request.membershipDiscountNote || '',
    membershipLevelSnapshot: request.membershipLevelSnapshot || '',
  }
}

async function expertCheckoutQuote(item = {}, user = null) {
  const bookingId = item.expertBookingId || item.expert_booking_id
  let priceAmount = 0
  if (isUuid(bookingId)) {
    const booking = await queryJson(`
      select json_build_object('priceAmount', e.price_amount)
      from expert_bookings b
      join expert_profiles e on e.id = b.expert_id
      where b.id = ${sqlLiteral(bookingId)}::uuid
      limit 1;
    `)
    if (booking) priceAmount = Number(booking.priceAmount || 0)
  }
  const expertId = item.expertId || item.expert_id
  if (!priceAmount && isUuid(expertId)) {
    const expert = await queryJson(`
      select json_build_object('priceAmount', price_amount)
      from expert_profiles
      where id = ${sqlLiteral(expertId)}::uuid
        and status = 'published'
        and deleted_at is null
      limit 1;
    `)
    if (expert) priceAmount = Number(expert.priceAmount || 0)
  }
  if (!priceAmount) {
    const product = await getProduct(item.productId || 'expert-city-consulting')
    priceAmount = Number(product?.priceAmount || 0)
  }
  const discountRate = memberDiscountRate(user?.membershipLevel || 'registered')
  const discountAmount = Math.round(priceAmount * discountRate)
  const unitPriceAmount = Math.max(priceAmount - discountAmount, 0)
  return {
    unitPriceAmount,
    listPriceAmount: priceAmount,
    memberPriceAmount: unitPriceAmount,
    membershipDiscountAmount: discountAmount,
    pricingSource: discountAmount > 0 ? 'expert_discount' : 'list_price',
  }
}

async function expertCheckoutPrice(item = {}, user = null) {
  const quote = await expertCheckoutQuote(item, user)
  return quote.unitPriceAmount
}

async function courseCheckoutQuote(productId, user) {
  const course = await queryJson(`
    select json_build_object(
      'accessPolicy', access_policy,
      'registeredAccess', registered_access,
      'juniorAccess', junior_access,
      'seniorAccess', senior_access,
      'registeredPriceAmount', registered_price_amount,
      'juniorPriceAmount', junior_price_amount,
      'seniorPriceAmount', senior_price_amount,
      'courseFormat', course_format,
      'checkinRequired', checkin_required,
      'capacity', capacity,
      'registeredCount', coalesce((
        select count(*)::int
        from course_registrations cr
        where cr.product_id = course_product_details.product_id
          and cr.status <> 'cancelled'
      ), 0),
      'userRegistered', ${user?.id ? `exists (
        select 1
        from course_registrations cr
        where cr.product_id = course_product_details.product_id
          and cr.user_id = ${sqlLiteral(user.id)}::uuid
          and cr.status <> 'cancelled'
      )` : 'false'}
    )
    from course_product_details
    where product_id = ${sqlLiteral(productId)}
      and status = 'published'
    limit 1;
  `)
  if (!course) return {
    unitPriceAmount: 0,
    listPriceAmount: 0,
    memberPriceAmount: 0,
    accessState: 'closed',
    pricingSource: 'matrix',
  }
  const needsRegistration = course.courseFormat === '线下培训' || course.checkinRequired
  if (needsRegistration && course.userRegistered) {
    const error = new Error('您已报名该线下课程')
    error.statusCode = 409
    error.code = 'course_already_registered'
    throw error
  }
  if (needsRegistration && course.capacity > 0 && course.registeredCount >= course.capacity) {
    const error = new Error('课程名额已满')
    error.statusCode = 409
    error.code = 'course_full'
    throw error
  }
  const level = user?.membershipLevel || 'registered'
  const accessState = courseAccessForMembership(course, level)
  if (accessState === 'closed') {
    const error = new Error('当前会员等级无法购买该课程')
    error.statusCode = 403
    error.code = 'course_access_denied'
    throw error
  }
  const listPriceAmount = coursePriceForMembership(course, 'registered')
  const unitPriceAmount = accessState === 'free' ? 0 : coursePriceForMembership(course, level)
  return {
    unitPriceAmount,
    listPriceAmount,
    memberPriceAmount: unitPriceAmount,
    accessState,
    pricingSource: 'matrix',
  }
}

async function courseCheckoutPrice(productId, user) {
  const quote = await courseCheckoutQuote(productId, user)
  return quote.unitPriceAmount
}

async function agentCheckoutQuote(productId, user) {
  const agent = await queryJson(`
    select json_build_object(
      'registeredAccess', registered_access,
      'juniorAccess', junior_access,
      'seniorAccess', senior_access,
      'registeredPriceAmount', registered_price_amount,
      'juniorPriceAmount', junior_price_amount,
      'seniorPriceAmount', senior_price_amount
    )
    from agent_product_details
    where product_id = ${sqlLiteral(productId)}
      and status = 'published'
    limit 1;
  `)
  if (!agent) return {
    unitPriceAmount: 0,
    listPriceAmount: 0,
    memberPriceAmount: 0,
    accessState: 'closed',
    pricingSource: 'matrix',
  }
  const level = user?.membershipLevel || 'registered'
  const accessState = agentAccessForMembership(agent, level)
  if (accessState === 'closed') {
    const error = new Error('当前会员等级无法购买该智能体')
    error.statusCode = 403
    error.code = 'agent_access_denied'
    throw error
  }
  const listPriceAmount = agentPriceForMembership(agent, 'registered')
  const unitPriceAmount = accessState === 'free' ? 0 : agentPriceForMembership(agent, level)
  return {
    unitPriceAmount,
    listPriceAmount,
    memberPriceAmount: unitPriceAmount,
    accessState,
    pricingSource: 'matrix',
  }
}

async function agentCheckoutPrice(productId, user) {
  const quote = await agentCheckoutQuote(productId, user)
  return quote.unitPriceAmount
}

async function eventCheckoutQuote(productId, user) {
  const eventProduct = await queryJson(`
    select json_build_object(
      'registeredAccess', registered_access,
      'juniorAccess', junior_access,
      'seniorAccess', senior_access,
      'registeredPriceAmount', registered_price_amount,
      'juniorPriceAmount', junior_price_amount,
      'seniorPriceAmount', senior_price_amount,
      'capacity', capacity,
      'registeredCount', coalesce((
        select count(*)::int
        from event_registrations er
        where er.product_id = event_product_details.product_id
          and er.status <> 'cancelled'
      ), 0)
    )
    from event_product_details
    where product_id = ${sqlLiteral(productId)}
      and status = 'published'
    limit 1;
  `)
  if (!eventProduct) return {
    unitPriceAmount: 0,
    listPriceAmount: 0,
    memberPriceAmount: 0,
    accessState: 'closed',
    pricingSource: 'matrix',
  }
  if (eventProduct.capacity > 0 && eventProduct.registeredCount >= eventProduct.capacity) {
    const error = new Error('活动名额已满')
    error.statusCode = 409
    error.code = 'event_full'
    throw error
  }
  const level = user?.membershipLevel || 'registered'
  const accessState = eventAccessForMembership(eventProduct, level)
  if (accessState === 'closed') {
    const error = new Error('当前会员等级无法报名该活动')
    error.statusCode = 403
    error.code = 'event_access_denied'
    throw error
  }
  const listPriceAmount = eventPriceForMembership(eventProduct, 'registered')
  const unitPriceAmount = accessState === 'free' ? 0 : eventPriceForMembership(eventProduct, level)
  return {
    unitPriceAmount,
    listPriceAmount,
    memberPriceAmount: unitPriceAmount,
    accessState,
    pricingSource: 'matrix',
  }
}

async function eventCheckoutPrice(productId, user) {
  const quote = await eventCheckoutQuote(productId, user)
  return quote.unitPriceAmount
}

async function membershipCheckoutQuote(productId, billingCycle, user = null) {
  const plan = await queryJson(`
    select json_build_object(
      'productId', product_id,
      'level', level,
      'monthlyPriceAmount', monthly_price_amount,
      'yearlyPriceAmount', yearly_price_amount
    )
    from membership_plans
    where product_id = ${sqlLiteral(productId)}
      and status = 'published'
    limit 1;
  `)
  if (!plan) return {
    unitPriceAmount: 0,
    listPriceAmount: 0,
    memberPriceAmount: 0,
    pricingSource: 'membership_plan',
    membershipChange: null,
  }
  const targetLevel = plan.level || membershipTierFromProductId(productId)
  const currentMembership = user?.resolvedMembership || (user?.id ? await resolveUserMembership(user.id) : {
    level: user?.membershipLevel || 'registered',
    activeSubscriptions: [],
  })
  if (targetLevel === 'junior' && membershipRank(currentMembership.level) >= membershipRank('senior')) {
    const error = new Error('高级会员有效期内无需开通初级会员')
    error.statusCode = 409
    error.code = 'membership_downgrade_blocked'
    throw error
  }
  const listPriceAmount = billingCycle === 'yearly' ? Number(plan.yearlyPriceAmount || 0) : Number(plan.monthlyPriceAmount || 0)
  const activeSameTier = (currentMembership.activeSubscriptions || []).some((item) => item.tier === targetLevel)
  let membershipChange = {
    type: activeSameTier ? 'renew' : 'activate',
    fromLevel: currentMembership.level || 'registered',
    toLevel: targetLevel,
    upgradeCreditAmount: 0,
    creditedSubscriptionIds: [],
  }
  let unitPriceAmount = listPriceAmount
  if (targetLevel === 'senior' && currentMembership.level === 'junior') {
    const credit = await calculateMembershipUpgradeCredit(user.id, listPriceAmount)
    unitPriceAmount = Math.max(listPriceAmount - credit.amount, 0)
    membershipChange = {
      type: 'upgrade',
      fromLevel: 'junior',
      toLevel: 'senior',
      upgradeCreditAmount: credit.amount,
      creditedSubscriptionIds: credit.subscriptionIds,
    }
  }
  return {
    unitPriceAmount,
    listPriceAmount,
    memberPriceAmount: unitPriceAmount,
    pricingSource: 'membership_plan',
    membershipChange,
  }
}

async function membershipCheckoutPrice(productId, billingCycle, user = null) {
  const quote = await membershipCheckoutQuote(productId, billingCycle, user)
  return quote.unitPriceAmount
}

async function calculateMembershipUpgradeCredit(userId, targetPriceAmount = 0) {
  if (!isUuid(userId)) return { amount: 0, subscriptionIds: [] }
  const items = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', ms.id,
      'billingCycle', ms.billing_cycle,
      'startsAt', ms.starts_at,
      'expiresAt', ms.expires_at,
      'paidAmountSnapshot', ms.paid_amount_snapshot,
      'paidAmount', coalesce(nullif(ms.paid_amount_snapshot, 0), oi.payable_amount, 0),
      'remainingSeconds', greatest(0, extract(epoch from (ms.expires_at - now()))),
      'totalSeconds', greatest(1, extract(epoch from (ms.expires_at - ms.starts_at)))
    ) order by ms.expires_at desc), '[]'::json)
    from membership_subscriptions ms
    left join order_items oi
      on oi.order_id = ms.source_order_id
     and oi.product_id = ms.product_id
    where ms.user_id = ${sqlLiteral(userId)}::uuid
      and ms.tier = 'junior'
      and ms.status = 'active'
      and ms.expires_at > now()
      and ms.billing_cycle in ('monthly', 'yearly')
      and ms.credited_to_subscription_id is null;
  `)
  let amount = 0
  const subscriptionIds = []
  for (const item of items || []) {
    const paidAmount = Math.max(0, Number(item.paidAmount || 0))
    const remainingSeconds = Math.max(0, Number(item.remainingSeconds || 0))
    const totalSeconds = Math.max(1, Number(item.totalSeconds || 1))
    const credit = Math.floor(paidAmount * remainingSeconds / totalSeconds)
    if (credit > 0) {
      amount += credit
      subscriptionIds.push(item.id)
    }
  }
  return {
    amount: Math.min(Math.max(0, amount), Math.max(0, Number(targetPriceAmount || 0))),
    subscriptionIds,
  }
}

export async function resolveUserMembership(userId) {
  const user = await getUserById(userId)
  if (!user) {
    return {
      level: 'registered',
      status: 'registered',
      subscription: null,
      activeSubscriptions: [],
      latestSubscription: null,
      expiresAt: null,
      daysLeft: null,
      autoRenew: false,
      canRenew: true,
      canUpgrade: true,
      generatedAt: new Date().toISOString(),
    }
  }
  const activeSubscriptions = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', id,
      'tier', tier,
      'productId', product_id,
      'billingCycle', billing_cycle,
      'status', status,
      'startsAt', starts_at,
      'expiresAt', expires_at,
      'daysLeft', greatest(0, ((expires_at at time zone 'Asia/Shanghai')::date - (now() at time zone 'Asia/Shanghai')::date)),
      'autoRenew', auto_renew,
      'sourceOrderId', source_order_id,
      'paidAmountSnapshot', paid_amount_snapshot,
      'cancelRequestedAt', cancel_requested_at,
      'cancelRequestedReason', cancel_requested_reason,
      'createdAt', created_at,
      'updatedAt', updated_at
    ) order by case tier when 'senior' then 2 when 'junior' then 1 else 0 end desc, expires_at desc), '[]'::json)
    from membership_subscriptions
    where user_id = ${sqlLiteral(userId)}::uuid
      and status = 'active'
      and expires_at > now();
  `)
  const latestSubscription = await queryJson(`
    select json_build_object(
      'id', id,
      'tier', tier,
      'productId', product_id,
      'billingCycle', billing_cycle,
      'status', status,
      'startsAt', starts_at,
      'expiresAt', expires_at,
      'daysLeft', greatest(0, ((expires_at at time zone 'Asia/Shanghai')::date - (now() at time zone 'Asia/Shanghai')::date)),
      'autoRenew', auto_renew,
      'sourceOrderId', source_order_id,
      'paidAmountSnapshot', paid_amount_snapshot,
      'cancelRequestedAt', cancel_requested_at,
      'cancelRequestedReason', cancel_requested_reason,
      'createdAt', created_at,
      'updatedAt', updated_at
    )
    from membership_subscriptions
    where user_id = ${sqlLiteral(userId)}::uuid
    order by expires_at desc, created_at desc
    limit 1;
  `)
  const primary = activeSubscriptions?.[0] || null
  const level = primary?.tier || 'registered'
  const status = primary
    ? Number(primary.daysLeft) <= 7 ? 'expiring' : 'active'
    : latestSubscription?.status === 'expired' ? 'expired' : 'registered'
  if ((user.membershipLevel || 'registered') !== level) {
    await syncUserMembershipLevel(userId, 'resolve')
  }

  return {
    level,
    status,
    tier: primary?.tier || (level === 'registered' ? null : level),
    subscription: primary,
    activeSubscriptions: activeSubscriptions || [],
    latestSubscription: latestSubscription || null,
    expiresAt: primary?.expiresAt || null,
    daysLeft: primary ? Number(primary.daysLeft) : null,
    autoRenew: Boolean(primary?.autoRenew),
    canRenew: true,
    canUpgrade: level === 'registered' || level === 'junior',
    generatedAt: new Date().toISOString(),
  }
}

async function getUserMembershipStatus(userId) {
  const membership = await resolveUserMembership(userId)
  const detail = await resolveMembershipBenefits(userId, membership)
  return {
    ...membership,
    benefits: detail.benefits,
    quotas: detail.quotas,
    serviceAssignments: detail.serviceAssignments,
    contentDeliveries: detail.contentDeliveries,
  }
}

export async function resolveMembershipBenefits(userId, membershipOverride = null) {
  const membership = membershipOverride || await resolveUserMembership(userId)
  const planProductId = membership.level === 'senior' ? 'membership-senior' : membership.level === 'junior' ? 'membership-junior' : ''
  const benefits = planProductId ? await queryJson(`
    select coalesce(json_agg(json_build_object(
      'code', r.code,
      'label', r.label,
      'groupKey', r.group_key,
      'benefitType', r.benefit_type,
      'targetType', r.target_type,
      'targetRef', r.target_ref,
      'executionMode', r.execution_mode,
      'defaultValue', r.default_value,
      'enabled', pb.enabled,
      'value', pb.value_json,
      'displayLabel', pb.display_label,
      'sortOrder', pb.sort_order,
      'status', pb.status,
      'description', r.description
    ) order by pb.sort_order, r.sort_order), '[]'::json)
    from membership_plan_benefits pb
    join membership_benefit_rules r on r.code = pb.benefit_code
    where pb.plan_product_id = ${sqlLiteral(planProductId)}
      and pb.enabled = true
      and pb.status = 'active'
      and r.status = 'active';
  `) : []
  const quotas = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', id,
      'userId', user_id,
      'subscriptionId', subscription_id,
      'benefitCode', benefit_code,
      'periodKey', period_key,
      'quotaTotal', quota_total,
      'quotaUsed', quota_used,
      'quotaRemaining', greatest(0, quota_total - quota_used),
      'validFrom', valid_from,
      'validTo', valid_to,
      'status', status,
      'metadata', metadata
    ) order by valid_to desc), '[]'::json)
    from membership_entitlement_accounts
    where user_id = ${sqlLiteral(userId)}::uuid
      and status = 'active'
      and valid_to > now();
  `)
  const serviceAssignments = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', msa.id,
      'userId', msa.user_id,
	      'subscriptionId', msa.subscription_id,
	      'assignmentType', msa.assignment_type,
	      'assignedStaffAccountId', msa.assigned_staff_account_id,
	      'assignedStaffName', coalesce(sa.display_name, sa.phone, sa.email, ''),
	      'assignedStaffPhone', sa.phone,
	      'status', msa.status,
	      'note', msa.note,
      'assignedAt', msa.assigned_at,
      'endedAt', msa.ended_at
    ) order by msa.assigned_at desc), '[]'::json)
    from membership_service_assignments msa
    left join staff_accounts sa on sa.id = msa.assigned_staff_account_id
    where msa.user_id = ${sqlLiteral(userId)}::uuid
      and msa.status = 'active';
  `)
  const contentDeliveries = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', id,
      'title', title,
      'contentType', content_type,
      'eligibleTiers', eligible_tiers,
      'fileId', file_id,
      'href', href,
      'periodKey', period_key,
      'status', status,
      'publishedAt', published_at,
      'createdAt', created_at,
      'updatedAt', updated_at
    ) order by period_key desc, published_at desc nulls last), '[]'::json)
    from membership_content_deliveries
    where status = 'published'
      and (${sqlLiteral(membership.level)} = any(eligible_tiers));
  `)
  return {
    membership,
    benefits: benefits || [],
    quotas: quotas || [],
    serviceAssignments: serviceAssignments || [],
    contentDeliveries: contentDeliveries || [],
  }
}

export async function getAccountMembership(token) {
  const { user } = await requireUser(token)
  return getUserMembershipStatus(user.id)
}

export async function cancelAccountMembershipSubscription(token, subscriptionId, payload = {}) {
  const { user } = await requireUser(token)
  const current = await queryJson(`
    select json_build_object(
      'id', id,
      'userId', user_id,
      'tier', tier,
      'status', status,
      'expiresAt', expires_at
    )
    from membership_subscriptions
    where id = ${sqlLiteral(subscriptionId)}::uuid
      and user_id = ${sqlLiteral(user.id)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('会员订阅不存在')
    error.statusCode = 404
    error.code = 'membership_subscription_not_found'
    throw error
  }
  if (current.status !== 'active') {
    const error = new Error('当前订阅不可提交取消申请')
    error.statusCode = 409
    error.code = 'membership_not_active'
    throw error
  }
  const reason = String(payload.reason || payload.cancelReason || payload.cancel_reason || '').trim()
  const updated = await queryJson(`
    update membership_subscriptions
    set cancel_requested_at = coalesce(cancel_requested_at, now()),
        cancel_requested_reason = ${sqlLiteral(reason)},
        cancelled_by = 'user',
        updated_at = now()
    where id = ${sqlLiteral(subscriptionId)}::uuid
      and user_id = ${sqlLiteral(user.id)}::uuid
    returning json_build_object(
      'id', id,
      'userId', user_id,
      'tier', tier,
      'productId', product_id,
      'billingCycle', billing_cycle,
      'status', status,
      'startsAt', starts_at,
      'expiresAt', expires_at,
      'cancelRequestedAt', cancel_requested_at,
      'cancelRequestedReason', cancel_requested_reason,
      'cancelledBy', cancelled_by,
      'updatedAt', updated_at
    );
  `)
  await writeMembershipEvent({
    subscriptionId: updated.id,
    userId: user.id,
    eventType: 'cancel_requested',
    fromTier: current.tier,
    toTier: current.tier,
    fromStatus: current.status,
    toStatus: updated.status,
    payload: { reason, actor: 'user' },
  })
  return updated
}

export async function listAccountNotifications(token, filters = {}) {
  const { user } = await requireUser(token)
  const unreadOnly = filters.unreadOnly === true || filters.unreadOnly === 'true'
  const readState = String(filters.readState || filters.read_state || '').trim()
  const category = String(filters.category || '').trim()
  const scene = String(filters.scene || '').trim()
  const page = Math.max(1, toSafeInteger(filters.page, 1))
  const pageSize = Math.min(80, Math.max(1, toSafeInteger(filters.pageSize ?? filters.page_size, 30)))
  const offset = (page - 1) * pageSize
  return queryJson(`
    with filtered as (
      select *
      from notification_messages
      where user_id = ${sqlLiteral(user.id)}::uuid
        and channel = 'in_app'
        and status in ('queued', 'sent')
        ${unreadOnly || readState === 'unread' ? 'and read_at is null' : ''}
        ${readState === 'read' ? 'and read_at is not null' : ''}
        ${category ? `and coalesce(payload_json->>'category', '') = ${sqlLiteral(category)}` : ''}
        ${scene ? `and scene = ${sqlLiteral(scene)}` : ''}
    )
    select json_build_object(
      'items', coalesce((
        select json_agg(json_build_object(
          'id', id,
          'batchId', batch_id,
          'scene', scene,
          'channel', channel,
          'templateKey', template_key,
          'title', coalesce(payload_json->>'title', ''),
          'body', coalesce(payload_json->>'body', ''),
          'actionUrl', coalesce(payload_json->>'actionUrl', ''),
          'category', coalesce(payload_json->>'category', ''),
          'payload', payload_json,
          'status', status,
          'priority', priority,
          'scheduledAt', scheduled_at,
          'sentAt', sent_at,
          'readAt', read_at,
          'createdAt', created_at,
          'updatedAt', updated_at
        ) order by created_at desc)
        from (
          select *
          from filtered
          order by created_at desc
          limit ${pageSize} offset ${offset}
        ) page_items
      ), '[]'::json),
      'page', ${page},
      'pageSize', ${pageSize},
      'total', (select count(*)::int from filtered),
      'unreadCount', (
        select count(*)::int
        from notification_messages
        where user_id = ${sqlLiteral(user.id)}::uuid
          and channel = 'in_app'
          and status in ('queued', 'sent')
          and read_at is null
      )
    );
  `)
}

export async function getAccountNotificationUnreadCount(token) {
  const { user } = await requireUser(token)
  return queryJson(`
    select json_build_object(
      'unreadCount', count(*)::int
    )
    from notification_messages
    where user_id = ${sqlLiteral(user.id)}::uuid
      and channel = 'in_app'
      and status in ('queued', 'sent')
      and read_at is null;
  `)
}

export async function markAccountNotificationRead(token, notificationId) {
  const { user } = await requireUser(token)
  const updated = await queryJson(`
    update notification_messages
    set read_at = coalesce(read_at, now()),
        updated_at = now()
    where id = ${sqlLiteral(notificationId)}::uuid
      and user_id = ${sqlLiteral(user.id)}::uuid
      and channel = 'in_app'
    returning json_build_object(
      'id', id,
      'readAt', read_at,
      'updatedAt', updated_at
    );
  `)
  if (!updated) {
    const error = new Error('消息不存在')
    error.statusCode = 404
    error.code = 'notification_not_found'
    throw error
  }
  return updated
}

export async function markAllAccountNotificationsRead(token, filters = {}) {
  const { user } = await requireUser(token)
  const category = String(filters.category || '').trim()
  const result = await queryJson(`
    with updated as (
      update notification_messages
      set read_at = coalesce(read_at, now()),
          updated_at = now()
      where user_id = ${sqlLiteral(user.id)}::uuid
        and channel = 'in_app'
        and status in ('queued', 'sent')
        and read_at is null
        ${category ? `and coalesce(payload_json->>'category', '') = ${sqlLiteral(category)}` : ''}
      returning id
    )
    select json_build_object('readCount', count(*)::int) from updated;
  `)
  return result || { readCount: 0 }
}

export async function getAccountNotificationPreferences(token) {
  const { user } = await requireUser(token)
  const preference = await queryJson(`
    insert into notification_preferences (user_id)
    values (${sqlLiteral(user.id)}::uuid)
    on conflict (user_id) do nothing
    returning json_build_object('created', true);
  `).catch(() => null)
  void preference
  return queryJson(`
    select json_build_object(
      'userId', user_id,
      'inAppEnabled', in_app_enabled,
      'smsEnabled', sms_enabled,
      'wechatSubscribeEnabled', wechat_subscribe_enabled,
      'emailEnabled', email_enabled,
      'quietHoursStart', quiet_hours_start,
      'quietHoursEnd', quiet_hours_end,
      'updatedAt', updated_at
    )
    from notification_preferences
    where user_id = ${sqlLiteral(user.id)}::uuid
    limit 1;
  `)
}

export async function updateAccountNotificationPreferences(token, payload = {}) {
  const { user } = await requireUser(token)
  const quietHoursStart = String(payload.quietHoursStart ?? payload.quiet_hours_start ?? '21:00').trim() || '21:00'
  const quietHoursEnd = String(payload.quietHoursEnd ?? payload.quiet_hours_end ?? '09:00').trim() || '09:00'
  await queryJson(`
    insert into notification_preferences (
      user_id, in_app_enabled, sms_enabled, wechat_subscribe_enabled, email_enabled,
      quiet_hours_start, quiet_hours_end, updated_at
    )
    values (
      ${sqlLiteral(user.id)}::uuid,
      ${payload.inAppEnabled === false || payload.in_app_enabled === false ? 'false' : 'true'},
      ${payload.smsEnabled === true || payload.sms_enabled === true ? 'true' : 'false'},
      ${payload.wechatSubscribeEnabled === true || payload.wechat_subscribe_enabled === true ? 'true' : 'false'},
      ${payload.emailEnabled === true || payload.email_enabled === true ? 'true' : 'false'},
      ${sqlLiteral(quietHoursStart)},
      ${sqlLiteral(quietHoursEnd)},
      now()
    )
    on conflict (user_id)
    do update set
      in_app_enabled = excluded.in_app_enabled,
      sms_enabled = excluded.sms_enabled,
      wechat_subscribe_enabled = excluded.wechat_subscribe_enabled,
      email_enabled = excluded.email_enabled,
      quiet_hours_start = excluded.quiet_hours_start,
      quiet_hours_end = excluded.quiet_hours_end,
      updated_at = now()
    returning json_build_object('ok', true);
  `)
  return getAccountNotificationPreferences(token)
}

export async function reconcileMembershipExpiry(token) {
  const { actor } = await requireAdmin(token, 'membership_subscription:update')
  const subscriptions = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', ms.id,
      'userId', ms.user_id,
      'tier', ms.tier,
      'status', ms.status,
      'expiresAt', ms.expires_at,
      'autoRenew', ms.auto_renew
    ) order by ms.expires_at), '[]'::json)
    from membership_subscriptions ms
    where ms.status = 'active'
      and ms.expires_at <= now();
  `)

  let downgradedCount = 0
  for (const subscription of subscriptions || []) {
    await executeSql(`
      update membership_subscriptions
      set status = 'expired',
          ended_at = expires_at,
          updated_at = now()
      where id = ${sqlLiteral(subscription.id)}::uuid
        and status = 'active';
    `)
    await writeMembershipEvent({
      subscriptionId: subscription.id,
      userId: subscription.userId,
      eventType: 'expired',
      fromTier: subscription.tier,
      toTier: subscription.tier,
      fromStatus: 'active',
      toStatus: 'expired',
      payload: { expiresAt: subscription.expiresAt, actor },
    })
    await queueNotificationMessage({
      userId: subscription.userId,
      scene: 'membership_expired',
      templateKey: 'membership_expired',
      dedupeKey: `membership:${subscription.id}:expired`,
      payload: {
        title: `${membershipLabel(subscription.tier)}已到期`,
        body: '会员折扣、专属课程活动和优先预约等权益已停止，可重新开通继续使用。',
        actionUrl: '/membership',
        tier: subscription.tier,
        expiresAt: subscription.expiresAt,
      },
    })
    const syncResult = await syncUserMembershipLevel(subscription.userId, 'expired')
    if (syncResult.changed && membershipRank(syncResult.newLevel) < membershipRank(syncResult.oldLevel)) downgradedCount += 1
  }

  await auditAdmin(actor, 'membership.expiry_reconcile', 'membership_subscription', 'batch', {
    expiredCount: subscriptions?.length || 0,
    downgradedCount,
  })

  return {
    expiredCount: subscriptions?.length || 0,
    downgradedCount,
    items: subscriptions || [],
  }
}

export async function createMembershipExpiryReminders(token) {
  const { actor } = await requireAdmin(token, 'ops_job:run')
  const subscriptions = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', ms.id,
      'userId', ms.user_id,
      'tier', ms.tier,
      'expiresAt', ms.expires_at,
      'daysUntilExpiry', ((ms.expires_at at time zone 'Asia/Shanghai')::date - (now() at time zone 'Asia/Shanghai')::date)
    ) order by ms.expires_at), '[]'::json)
    from membership_subscriptions ms
    where ms.status = 'active'
      and ms.expires_at > now()
      and ((ms.expires_at at time zone 'Asia/Shanghai')::date - (now() at time zone 'Asia/Shanghai')::date) in (0, 1, 3, 7);
  `)

  let queuedCount = 0
  for (const subscription of subscriptions || []) {
    const daysUntilExpiry = Number(subscription.daysUntilExpiry)
    const scene = membershipReminderScene(daysUntilExpiry)
    if (!scene) continue
    await queueNotificationMessage({
      userId: subscription.userId,
      scene,
      templateKey: scene,
      dedupeKey: `membership:${subscription.id}:${scene}`,
      payload: {
        title: membershipReminderTitle(daysUntilExpiry, subscription.tier),
        body: membershipReminderBody(daysUntilExpiry),
        actionUrl: '/membership',
        tier: subscription.tier,
        expiresAt: subscription.expiresAt,
        daysUntilExpiry,
      },
    })
    queuedCount += 1
  }

  await auditAdmin(actor, 'membership.expiry_reminders', 'notification_message', 'batch', {
    queuedCount,
  })

  return { queuedCount, items: subscriptions || [] }
}

export async function createMembershipWinbackReminders(token) {
  const { actor } = await requireAdmin(token, 'ops_job:run')
  const users = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'userId', ranked.user_id,
      'fromTier', ranked.from_tier,
      'downgradedAt', ranked.created_at,
      'daysSinceDowngrade', ((now() at time zone 'Asia/Shanghai')::date - (ranked.created_at at time zone 'Asia/Shanghai')::date)
    ) order by ranked.created_at), '[]'::json)
    from (
      select distinct on (me.user_id)
        me.user_id,
        me.from_tier,
        me.created_at
      from membership_events me
      where me.event_type = 'downgraded'
        and coalesce(me.to_tier, 'registered') = 'registered'
        and ((now() at time zone 'Asia/Shanghai')::date - (me.created_at at time zone 'Asia/Shanghai')::date) in (1, 7, 30)
        and not exists (
          select 1
          from membership_subscriptions ms
          where ms.user_id = me.user_id
            and ms.status = 'active'
            and ms.expires_at > now()
        )
      order by me.user_id, me.created_at desc
    ) ranked;
  `)

  let queuedCount = 0
  for (const item of users || []) {
    const day = Number(item.daysSinceDowngrade)
    const scene = day === 1 ? 'membership_winback_d1' : day === 7 ? 'membership_winback_d7' : day === 30 ? 'membership_winback_d30' : ''
    if (!scene) continue
    await queueNotificationMessage({
      userId: item.userId,
      scene,
      templateKey: scene,
      dedupeKey: `membership:${item.userId}:${scene}:${String(item.downgradedAt).slice(0, 10)}`,
      payload: {
        title: '会员权益可重新开通',
        body: day === 30
          ? '近期课程、活动和专家服务仍可使用会员价，可按需要重新开通。'
          : '重新开通后可继续使用会员折扣、专属内容和优先服务。',
        actionUrl: '/membership',
        daysSinceDowngrade: day,
        previousTier: item.fromTier,
      },
    })
    queuedCount += 1
  }

  await auditAdmin(actor, 'membership.winback_reminders', 'notification_message', 'batch', {
    queuedCount,
  })

  return { queuedCount, items: users || [] }
}

export async function listAdminNotificationTemplates(token, filters = {}) {
  await requireAdminAny(token, ['notification:read', 'notification_template:update'])
  const status = notificationTemplateStatuses.has(filters.status) ? filters.status : ''
  const category = String(filters.category || '').trim()
  const scene = String(filters.scene || '').trim()
  const search = String(filters.search || '').trim()
  return queryJson(`
    select coalesce(json_agg(json_build_object(
      'templateKey', template_key,
      'scene', scene,
      'category', category,
      'titleTemplate', title_template,
      'bodyTemplate', body_template,
      'actionUrlTemplate', action_url_template,
      'defaultChannels', default_channels,
      'priority', priority,
      'variablesSchema', variables_schema,
      'status', status,
      'sortOrder', sort_order,
      'createdAt', created_at,
      'updatedAt', updated_at
    ) order by sort_order, template_key), '[]'::json)
    from notification_templates
    where 1 = 1
      ${status ? `and status = ${sqlLiteral(status)}` : ''}
      ${category ? `and category = ${sqlLiteral(category)}` : ''}
      ${scene ? `and scene = ${sqlLiteral(scene)}` : ''}
      ${search ? `and (
        template_key ilike ${sqlLiteral(`%${search}%`)}
        or scene ilike ${sqlLiteral(`%${search}%`)}
        or title_template ilike ${sqlLiteral(`%${search}%`)}
        or body_template ilike ${sqlLiteral(`%${search}%`)}
      )` : ''}
    limit 500;
  `)
}

export async function updateAdminNotificationTemplate(token, templateKey, payload = {}) {
  const { actor } = await requireAdmin(token, 'notification_template:update')
  const key = String(templateKey || '').trim()
  const current = await getNotificationTemplate(key)
  if (!current) {
    const error = new Error('通知模板不存在')
    error.statusCode = 404
    error.code = 'notification_template_not_found'
    throw error
  }
  const titleTemplate = payload.titleTemplate !== undefined || payload.title_template !== undefined
    ? String(payload.titleTemplate ?? payload.title_template ?? '').trim()
    : current.titleTemplate
  const bodyTemplate = payload.bodyTemplate !== undefined || payload.body_template !== undefined
    ? String(payload.bodyTemplate ?? payload.body_template ?? '').trim()
    : current.bodyTemplate
  if (!titleTemplate || !bodyTemplate) {
    const error = new Error('通知模板标题和正文不能为空')
    error.statusCode = 400
    error.code = 'invalid_notification_template'
    throw error
  }
  const actionUrlTemplate = payload.actionUrlTemplate !== undefined || payload.action_url_template !== undefined
    ? String(payload.actionUrlTemplate ?? payload.action_url_template ?? '').trim()
    : current.actionUrlTemplate
  const defaultChannels = payload.defaultChannels !== undefined || payload.default_channels !== undefined
    ? normalizeNotificationChannels(payload.defaultChannels ?? payload.default_channels)
    : normalizeNotificationChannels(current.defaultChannels)
  const priority = normalizeNotificationPriority(payload.priority || current.priority)
  const status = notificationTemplateStatuses.has(payload.status) ? payload.status : current.status
  const variablesSchema = payload.variablesSchema || payload.variables_schema || current.variablesSchema || {}
  const updated = await queryJson(`
    update notification_templates
    set title_template = ${sqlLiteral(titleTemplate)},
        body_template = ${sqlLiteral(bodyTemplate)},
        action_url_template = ${sqlLiteral(actionUrlTemplate)},
        default_channels = ${sqlTextArray(defaultChannels)},
        priority = ${sqlLiteral(priority)},
        variables_schema = ${sqlJsonb(variablesSchema)},
        status = ${sqlLiteral(status)},
        updated_at = now()
    where template_key = ${sqlLiteral(key)}
    returning json_build_object(
      'templateKey', template_key,
      'scene', scene,
      'category', category,
      'titleTemplate', title_template,
      'bodyTemplate', body_template,
      'actionUrlTemplate', action_url_template,
      'defaultChannels', default_channels,
      'priority', priority,
      'variablesSchema', variables_schema,
      'status', status,
      'sortOrder', sort_order,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `)
  await auditAdmin(actor, 'notification_template.update', 'notification_template', key, updated)
  return updated
}

function normalizeNotificationTargetType(value = 'all') {
  const targetType = String(value || 'all').trim()
  const aliasMap = {
    specific_users: 'user_ids',
    specified_users: 'user_ids',
    users: 'user_ids',
    user: 'user_ids',
    membership_levels: 'membership_level',
    order_statuses: 'order_status',
    service_statuses: 'service_status',
    course_registration: 'course_registrations',
    event_registration: 'event_registrations',
    expert: 'experts',
  }
  const normalized = aliasMap[targetType] || targetType
  return notificationTargetTypes.has(normalized) ? normalized : 'all'
}

function notificationFilterObject(value = {}) {
  if (value && typeof value === 'object' && !Array.isArray(value)) return value
  if (typeof value === 'string' && value.trim()) {
    try {
      const parsed = JSON.parse(value)
      return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
    } catch {
      return {}
    }
  }
  return {}
}

async function resolveNotificationRecipients(targetType, filters = {}) {
  const type = normalizeNotificationTargetType(targetType)
  const targetFilters = notificationFilterObject(filters)
  const activeUserWhere = "u.status = 'active'"
  const userJson = `json_build_object(
    'userId', u.id,
    'userName', u.display_name,
    'userPhone', u.phone,
    'passportNo', u.passport_no,
    'membershipLevel', u.membership_level
  )`

  if (type === 'all') {
    return queryJson(`
      select coalesce(json_agg(${userJson} order by u.created_at desc), '[]'::json)
      from users u
      where ${activeUserWhere}
      limit 10000;
    `)
  }

  if (type === 'user_ids') {
    const keys = compactStringArray(targetFilters.userIds || targetFilters.user_ids || targetFilters.userKeys || targetFilters.user_keys)
    if (!keys.length) return []
    const uuidKeys = keys.filter(isUuid)
    const textKeys = keys.filter((item) => !isUuid(item))
    const clauses = [
      uuidKeys.length ? `u.id = any(${sqlUuidArray(uuidKeys)})` : '',
      textKeys.length ? `(u.phone = any(${sqlTextArray(textKeys)}) or u.passport_no = any(${sqlTextArray(textKeys)}))` : '',
    ].filter(Boolean)
    if (!clauses.length) return []
    return queryJson(`
      select coalesce(json_agg(${userJson} order by u.created_at desc), '[]'::json)
      from users u
      where ${activeUserWhere}
        and (${clauses.join(' or ')})
      limit 10000;
    `)
  }

  if (type === 'membership_level') {
    const levels = compactStringArray(targetFilters.membershipLevels || targetFilters.membership_levels || targetFilters.levels || targetFilters.level)
      .filter((item) => ['registered', 'junior', 'senior'].includes(item))
    if (!levels.length) return []
    return queryJson(`
      select coalesce(json_agg(${userJson} order by u.created_at desc), '[]'::json)
      from users u
      where ${activeUserWhere}
        and u.membership_level = any(${sqlTextArray(levels)})
      limit 10000;
    `)
  }

  if (type === 'order_status') {
    const statuses = compactStringArray(targetFilters.statuses || targetFilters.status || targetFilters.orderStatuses || targetFilters.order_statuses)
    return queryJson(`
      select coalesce(json_agg(json_build_object(
        'userId', ranked.id,
        'userName', ranked.display_name,
        'userPhone', ranked.phone,
        'passportNo', ranked.passport_no,
        'membershipLevel', ranked.membership_level
      ) order by ranked.last_at desc), '[]'::json)
      from (
        select u.id, u.display_name, u.phone, u.passport_no, u.membership_level, max(o.created_at) as last_at
        from users u
        join orders o on o.user_id = u.id
        where ${activeUserWhere}
          ${statuses.length ? `and o.status = any(${sqlTextArray(statuses)})` : ''}
        group by u.id, u.display_name, u.phone, u.passport_no, u.membership_level
        order by last_at desc
        limit 10000
      ) ranked;
    `)
  }

  if (type === 'service_status') {
    const statuses = compactStringArray(targetFilters.statuses || targetFilters.status || targetFilters.serviceStatuses || targetFilters.service_statuses)
    return queryJson(`
      with service_users as (
        select user_id, max(updated_at) as updated_at
        from consulting_requests
        where 1 = 1
          ${statuses.length ? `and status = any(${sqlTextArray(statuses)})` : ''}
        group by user_id
        union all
        select user_id, max(updated_at) as updated_at
        from data_service_requests
        where 1 = 1
          ${statuses.length ? `and status = any(${sqlTextArray(statuses)})` : ''}
        group by user_id
      )
      select coalesce(json_agg(json_build_object(
        'userId', ranked.id,
        'userName', ranked.display_name,
        'userPhone', ranked.phone,
        'passportNo', ranked.passport_no,
        'membershipLevel', ranked.membership_level
      ) order by ranked.last_at desc), '[]'::json)
      from (
        select u.id, u.display_name, u.phone, u.passport_no, u.membership_level, max(su.updated_at) as last_at
        from users u
        join service_users su on su.user_id = u.id
        where ${activeUserWhere}
        group by u.id, u.display_name, u.phone, u.passport_no, u.membership_level
        order by last_at desc
        limit 10000
      ) ranked;
    `)
  }

  if (type === 'course_registrations') {
    const statuses = compactStringArray(targetFilters.statuses || targetFilters.status || targetFilters.registrationStatuses || targetFilters.registration_statuses)
    const productIds = compactStringArray(targetFilters.productIds || targetFilters.product_ids || targetFilters.courseIds || targetFilters.course_ids)
    return queryJson(`
      select coalesce(json_agg(json_build_object(
        'userId', ranked.id,
        'userName', ranked.display_name,
        'userPhone', ranked.phone,
        'passportNo', ranked.passport_no,
        'membershipLevel', ranked.membership_level
      ) order by ranked.last_at desc), '[]'::json)
      from (
        select u.id, u.display_name, u.phone, u.passport_no, u.membership_level, max(cr.created_at) as last_at
        from users u
        join course_registrations cr on cr.user_id = u.id
        where ${activeUserWhere}
          ${statuses.length ? `and cr.status = any(${sqlTextArray(statuses)})` : ''}
          ${productIds.length ? `and cr.product_id = any(${sqlTextArray(productIds)})` : ''}
        group by u.id, u.display_name, u.phone, u.passport_no, u.membership_level
        order by last_at desc
        limit 10000
      ) ranked;
    `)
  }

  if (type === 'event_registrations') {
    const statuses = compactStringArray(targetFilters.statuses || targetFilters.status || targetFilters.registrationStatuses || targetFilters.registration_statuses)
    const productIds = compactStringArray(targetFilters.productIds || targetFilters.product_ids || targetFilters.eventIds || targetFilters.event_ids)
    return queryJson(`
      select coalesce(json_agg(json_build_object(
        'userId', ranked.id,
        'userName', ranked.display_name,
        'userPhone', ranked.phone,
        'passportNo', ranked.passport_no,
        'membershipLevel', ranked.membership_level
      ) order by ranked.last_at desc), '[]'::json)
      from (
        select u.id, u.display_name, u.phone, u.passport_no, u.membership_level, max(er.created_at) as last_at
        from users u
        join event_registrations er on er.user_id = u.id
        where ${activeUserWhere}
          ${statuses.length ? `and er.status = any(${sqlTextArray(statuses)})` : ''}
          ${productIds.length ? `and er.product_id = any(${sqlTextArray(productIds)})` : ''}
        group by u.id, u.display_name, u.phone, u.passport_no, u.membership_level
        order by last_at desc
        limit 10000
      ) ranked;
    `)
  }

  if (type === 'experts') {
    const statuses = compactStringArray(targetFilters.statuses || targetFilters.status || targetFilters.expertStatuses || targetFilters.expert_statuses)
    const expertStatusSql = statuses.length ? sqlTextArray(statuses) : sqlTextArray(['published'])
    return queryJson(`
      select coalesce(json_agg(json_build_object(
        'userId', ranked.id,
        'userName', ranked.display_name,
        'userPhone', ranked.phone,
        'passportNo', ranked.passport_no,
        'membershipLevel', ranked.membership_level
      ) order by ranked.last_at desc), '[]'::json)
      from (
        select u.id, u.display_name, u.phone, u.passport_no, u.membership_level, max(ep.updated_at) as last_at
        from users u
        join expert_profiles ep on ep.user_id = u.id
        where ${activeUserWhere}
          and ep.deleted_at is null
          and ep.status = any(${expertStatusSql})
        group by u.id, u.display_name, u.phone, u.passport_no, u.membership_level
        order by last_at desc
        limit 10000
      ) ranked;
    `)
  }

  return []
}

function normalizeNotificationBatchPayload(payload = {}) {
  const title = String(payload.title || '').trim()
  const body = String(payload.body || '').trim()
  if (!title || !body) {
    const error = new Error('人工消息标题和正文不能为空')
    error.statusCode = 400
    error.code = 'invalid_notification_batch'
    throw error
  }
  const targetType = normalizeNotificationTargetType(payload.targetType || payload.target_type || 'all')
  const targetFilters = notificationFilterObject(
    payload.targetFilters
      || payload.target_filters
      || payload.targetConfig
      || payload.target_config
      || payload.filters
      || {},
  )
  return {
    title,
    body,
    actionUrl: String(payload.actionUrl || payload.action_url || '').trim(),
    targetType,
    targetFilters,
    channels: normalizeNotificationChannels(payload.channels || ['in_app']),
    priority: normalizeNotificationPriority(payload.priority || 'normal'),
    scheduledAt: String(payload.scheduledAt || payload.scheduled_at || '').trim(),
  }
}

export async function createAdminNotificationBatch(token, payload = {}) {
  const { actor } = await requireAdmin(token, 'notification:write')
  const normalized = normalizeNotificationBatchPayload(payload)
  const recipients = await resolveNotificationRecipients(normalized.targetType, normalized.targetFilters)
  const batchNo = await nextBusinessNo('NTF')
  const status = normalized.scheduledAt ? 'scheduled' : 'draft'
  const item = await queryJson(`
    insert into notification_batches (
      batch_no, title, body, action_url, target_type, target_filters,
      channels, status, scheduled_at, recipient_count, created_by
    )
    values (
      ${sqlLiteral(batchNo)},
      ${sqlLiteral(normalized.title)},
      ${sqlLiteral(normalized.body)},
      ${sqlLiteral(normalized.actionUrl)},
      ${sqlLiteral(normalized.targetType)},
      ${sqlJsonb({ ...normalized.targetFilters, priority: normalized.priority })},
      ${sqlTextArray(normalized.channels)},
      ${sqlLiteral(status)},
      ${normalized.scheduledAt ? sqlLiteral(normalized.scheduledAt) : 'null'},
      ${Number(recipients?.length || 0)},
      ${sqlLiteral(actor)}
    )
    returning json_build_object(
      'id', id,
      'batchNo', batch_no,
      'title', title,
      'body', body,
      'actionUrl', action_url,
      'targetType', target_type,
      'targetFilters', target_filters,
      'channels', channels,
      'priority', target_filters->>'priority',
      'status', status,
      'scheduledAt', scheduled_at,
      'sentAt', sent_at,
      'recipientCount', recipient_count,
      'sentCount', sent_count,
      'skippedCount', skipped_count,
      'createdBy', created_by,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `)
  await auditAdmin(actor, 'notification_batch.create', 'notification_batch', item.id, {
    targetType: normalized.targetType,
    recipientCount: recipients?.length || 0,
    channels: normalized.channels,
  })
  if (payload.sendNow === true || payload.send_now === true) {
    return sendAdminNotificationBatch(token, item.id)
  }
  return {
    ...item,
    previewRecipients: (recipients || []).slice(0, 20),
  }
}

export async function listAdminNotificationBatches(token, filters = {}) {
  await requireAdmin(token, 'notification:read')
  const status = notificationBatchStatuses.has(filters.status) ? filters.status : ''
  const search = String(filters.search || '').trim()
  const page = Math.max(1, toSafeInteger(filters.page, 1))
  const pageSize = Math.min(100, Math.max(1, toSafeInteger(filters.pageSize ?? filters.page_size, 30)))
  const offset = (page - 1) * pageSize
  return queryJson(`
    with filtered as (
      select *
      from notification_batches
      where 1 = 1
        ${status ? `and status = ${sqlLiteral(status)}` : ''}
        ${search ? `and (batch_no ilike ${sqlLiteral(`%${search}%`)} or title ilike ${sqlLiteral(`%${search}%`)})` : ''}
    )
    select json_build_object(
      'items', coalesce((
        select json_agg(json_build_object(
          'id', id,
          'batchNo', batch_no,
          'title', title,
          'body', body,
          'actionUrl', action_url,
          'targetType', target_type,
          'targetFilters', target_filters,
          'channels', channels,
          'priority', target_filters->>'priority',
          'status', status,
          'scheduledAt', scheduled_at,
          'sentAt', sent_at,
          'recipientCount', recipient_count,
          'sentCount', sent_count,
          'skippedCount', skipped_count,
          'createdBy', created_by,
          'createdAt', created_at,
          'updatedAt', updated_at
        ) order by created_at desc)
        from (
          select *
          from filtered
          order by created_at desc
          limit ${pageSize} offset ${offset}
        ) page_items
      ), '[]'::json),
      'page', ${page},
      'pageSize', ${pageSize},
      'total', (select count(*)::int from filtered)
    );
  `)
}

export async function getAdminNotificationBatch(token, batchId) {
  await requireAdmin(token, 'notification:read')
  if (!isUuid(batchId)) {
    const error = new Error('通知批次不存在')
    error.statusCode = 404
    error.code = 'notification_batch_not_found'
    throw error
  }
  const item = await queryJson(`
    select json_build_object(
      'id', nb.id,
      'batchNo', nb.batch_no,
      'title', nb.title,
      'body', nb.body,
      'actionUrl', nb.action_url,
      'targetType', nb.target_type,
      'targetFilters', nb.target_filters,
      'channels', nb.channels,
      'priority', nb.target_filters->>'priority',
      'status', nb.status,
      'scheduledAt', nb.scheduled_at,
      'sentAt', nb.sent_at,
      'recipientCount', nb.recipient_count,
      'sentCount', nb.sent_count,
      'skippedCount', nb.skipped_count,
      'createdBy', nb.created_by,
      'createdAt', nb.created_at,
      'updatedAt', nb.updated_at,
      'messages', coalesce((
        select json_agg(json_build_object(
          'id', nm.id,
          'userId', nm.user_id,
          'userName', u.display_name,
          'userPhone', u.phone,
          'passportNo', u.passport_no,
          'scene', nm.scene,
          'channel', nm.channel,
          'templateKey', nm.template_key,
          'title', coalesce(nm.payload_json->>'title', ''),
          'body', coalesce(nm.payload_json->>'body', ''),
          'actionUrl', coalesce(nm.payload_json->>'actionUrl', ''),
          'category', coalesce(nm.payload_json->>'category', ''),
          'status', nm.status,
          'priority', nm.priority,
          'scheduledAt', nm.scheduled_at,
          'sentAt', nm.sent_at,
          'readAt', nm.read_at,
          'failedReason', nm.failed_reason,
          'createdAt', nm.created_at,
          'updatedAt', nm.updated_at
        ) order by nm.created_at desc)
        from notification_messages nm
        join users u on u.id = nm.user_id
        where nm.batch_id = nb.id
        limit 200
      ), '[]'::json)
    )
    from notification_batches nb
    where nb.id = ${sqlLiteral(batchId)}::uuid
    limit 1;
  `)
  if (!item) {
    const error = new Error('通知批次不存在')
    error.statusCode = 404
    error.code = 'notification_batch_not_found'
    throw error
  }
  return item
}

export async function sendAdminNotificationBatch(token, batchId) {
  const { actor } = await requireAdmin(token, 'notification:dispatch')
  const batch = await getAdminNotificationBatch(token, batchId)
  if (['sending', 'sent', 'cancelled'].includes(batch.status)) {
    const error = new Error('当前通知批次不可重复发送')
    error.statusCode = 409
    error.code = 'notification_batch_not_sendable'
    throw error
  }
  const recipients = await resolveNotificationRecipients(batch.targetType, batch.targetFilters || {})
  const channels = normalizeNotificationChannels(batch.channels)
  const priority = normalizeNotificationPriority(batch.priority || 'normal')
  await executeSql(`
    update notification_batches
    set status = 'sending',
        recipient_count = ${Number(recipients?.length || 0)},
        updated_at = now()
    where id = ${sqlLiteral(batch.id)}::uuid;
  `)
  let sentCount = 0
  let skippedCount = 0
  for (const recipient of recipients || []) {
    for (const channel of channels) {
      const result = await queueNotificationMessage({
        userId: recipient.userId,
        scene: 'platform_manual',
        templateKey: 'platform_announcement',
        payload: {
          title: batch.title,
          body: batch.body,
          actionUrl: batch.actionUrl || '',
          category: 'manual',
        },
        channel,
        dedupeKey: `notification-batch:${batch.id}:${recipient.userId}:${channel}`,
        scheduledAt: 'now()',
        batchId: batch.id,
        priority,
      })
      if (result?.sent) sentCount += 1
      if (result?.skipped) skippedCount += 1
    }
  }
  const updated = await queryJson(`
    update notification_batches
    set status = 'sent',
        sent_at = now(),
        recipient_count = ${Number(recipients?.length || 0)},
        sent_count = ${sentCount},
        skipped_count = ${skippedCount},
        updated_at = now()
    where id = ${sqlLiteral(batch.id)}::uuid
    returning json_build_object(
      'id', id,
      'batchNo', batch_no,
      'title', title,
      'body', body,
      'actionUrl', action_url,
      'targetType', target_type,
      'targetFilters', target_filters,
      'channels', channels,
      'priority', target_filters->>'priority',
      'status', status,
      'scheduledAt', scheduled_at,
      'sentAt', sent_at,
      'recipientCount', recipient_count,
      'sentCount', sent_count,
      'skippedCount', skipped_count,
      'createdBy', created_by,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `)
  await auditAdmin(actor, 'notification_batch.send', 'notification_batch', batch.id, {
    recipientCount: recipients?.length || 0,
    sentCount,
    skippedCount,
    channels,
  })
  return updated
}

export async function dispatchNotificationMessages(token) {
  const { actor } = await requireAdmin(token, 'notification:dispatch')
  const result = await queryJson(`
    with updated_in_app as (
      update notification_messages
      set status = 'sent',
          sent_at = coalesce(sent_at, now()),
          updated_at = now()
      where status = 'queued'
        and channel = 'in_app'
        and scheduled_at <= now()
      returning id
    ),
    skipped_external as (
      update notification_messages
      set status = 'skipped',
          failed_reason = '外部通知通道尚未接入',
          updated_at = now()
      where status = 'queued'
        and channel <> 'in_app'
        and scheduled_at <= now()
      returning id
    )
    select json_build_object(
      'sentCount', (select count(*)::int from updated_in_app),
      'skippedCount', (select count(*)::int from skipped_external)
    );
  `)
  await auditAdmin(actor, 'notification.dispatch', 'notification_message', 'batch', result || {})
  return result || { sentCount: 0, skippedCount: 0 }
}

export async function retryAdminNotification(token, notificationId) {
  const { actor } = await requireAdmin(token, 'notification:dispatch')
  const current = await queryJson(`
    select json_build_object(
      'id', id,
      'userId', user_id,
      'channel', channel,
      'status', status,
      'priority', priority
    )
    from notification_messages
    where id = ${sqlLiteral(notificationId)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('通知消息不存在')
    error.statusCode = 404
    error.code = 'notification_not_found'
    throw error
  }
  if (!['failed', 'skipped', 'queued'].includes(current.status)) {
    const error = new Error('当前通知状态不可重试')
    error.statusCode = 409
    error.code = 'notification_not_retryable'
    throw error
  }
  const preference = current.priority === 'urgent' ? null : await getUserNotificationPreference(current.userId)
  const preferenceBlocked = current.priority !== 'urgent' && !notificationChannelAllowed(preference, current.channel)
  const finalStatus = preferenceBlocked || current.channel !== 'in_app' ? 'skipped' : 'sent'
  const failedReason = preferenceBlocked
    ? '用户未授权该通知通道'
    : current.channel !== 'in_app' ? '外部通知通道尚未接入' : ''
  const updated = await queryJson(`
    update notification_messages nm
    set status = ${sqlLiteral(finalStatus)},
        sent_at = case when ${sqlLiteral(finalStatus)} = 'sent' then now() else null end,
        failed_reason = ${sqlLiteral(failedReason)},
        updated_at = now()
    where nm.id = ${sqlLiteral(notificationId)}::uuid
    returning json_build_object(
      'id', nm.id,
      'userId', nm.user_id,
      'scene', nm.scene,
      'channel', nm.channel,
      'templateKey', nm.template_key,
      'title', coalesce(nm.payload_json->>'title', ''),
      'body', coalesce(nm.payload_json->>'body', ''),
      'actionUrl', coalesce(nm.payload_json->>'actionUrl', ''),
      'category', coalesce(nm.payload_json->>'category', ''),
      'payload', nm.payload_json,
      'status', nm.status,
      'priority', nm.priority,
      'scheduledAt', nm.scheduled_at,
      'sentAt', nm.sent_at,
      'readAt', nm.read_at,
      'failedReason', nm.failed_reason,
      'createdAt', nm.created_at,
      'updatedAt', nm.updated_at
    );
  `)
  await auditAdmin(actor, 'notification.retry', 'notification_message', notificationId, {
    fromStatus: current.status,
    toStatus: updated.status,
    channel: current.channel,
  })
  return updated
}

export async function listAdminMembershipSubscriptions(token, filters = {}) {
  await requireAdminAny(token, ['membership_subscription:read', 'membership_subscription:update'])
  const tier = membershipTiers.has(filters.tier) ? filters.tier : ''
  const status = ['active', 'past_due', 'expired', 'canceled', 'refunded'].includes(filters.status) ? filters.status : ''
  const search = String(filters.search || '').trim()
  return queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', ms.id,
      'userId', ms.user_id,
      'userName', u.display_name,
      'userPhone', u.phone,
      'passportNo', u.passport_no,
      'currentMembershipLevel', u.membership_level,
      'tier', ms.tier,
      'productId', ms.product_id,
      'billingCycle', ms.billing_cycle,
      'status', ms.status,
      'startsAt', ms.starts_at,
      'expiresAt', ms.expires_at,
      'daysLeft', greatest(0, ((ms.expires_at at time zone 'Asia/Shanghai')::date - (now() at time zone 'Asia/Shanghai')::date)),
      'autoRenew', ms.auto_renew,
      'sourceOrderId', ms.source_order_id,
      'latestRenewalOrderId', ms.latest_renewal_order_id,
      'cancelRequestedAt', ms.cancel_requested_at,
      'cancelRequestedReason', ms.cancel_requested_reason,
      'cancelledBy', ms.cancelled_by,
      'creditedToSubscriptionId', ms.credited_to_subscription_id,
      'paidAmountSnapshot', ms.paid_amount_snapshot,
      'customerManager', coalesce((
        select json_build_object(
          'assignmentId', msa.id,
          'staffAccountId', msa.assigned_staff_account_id,
          'staffName', sa.display_name,
          'staffPhone', sa.phone,
          'note', msa.note,
          'assignedAt', msa.assigned_at
        )
        from membership_service_assignments msa
        left join staff_accounts sa on sa.id = msa.assigned_staff_account_id
        where msa.subscription_id = ms.id
          and msa.assignment_type = 'customer_manager'
          and msa.status = 'active'
        order by msa.assigned_at desc
        limit 1
      ), '{}'::json),
      'createdAt', ms.created_at,
      'updatedAt', ms.updated_at,
      'messageCount', coalesce((
        select count(*)::int
        from notification_messages nm
        where nm.user_id = ms.user_id
          and nm.scene like 'membership%'
      ), 0)
    ) order by ms.expires_at desc), '[]'::json)
    from membership_subscriptions ms
    join users u on u.id = ms.user_id
    where 1 = 1
      ${tier ? `and ms.tier = ${sqlLiteral(tier)}` : ''}
      ${status ? `and ms.status = ${sqlLiteral(status)}` : ''}
      ${search ? `and (
        u.display_name ilike ${sqlLiteral(`%${search}%`)}
        or u.phone ilike ${sqlLiteral(`%${search}%`)}
        or u.passport_no ilike ${sqlLiteral(`%${search}%`)}
      )` : ''}
    limit 200;
  `)
}

export async function manualGrantAdminMembership(token, payload = {}) {
  const { actor } = await requireAdmin(token, 'membership_subscription:update')
  const tier = String(payload.tier || '').trim()
  if (!membershipTiers.has(tier)) {
    const error = new Error('会员等级无效')
    error.statusCode = 400
    error.code = 'invalid_membership_tier'
    throw error
  }

  const userKey = payload.userId || payload.user_id || payload.passportNo || payload.passport_no || payload.phone || ''
  const user = await queryJson(`
    select ${userJson('u')}
    from users u
    where u.id::text = ${sqlLiteral(userKey)}
       or u.passport_no = ${sqlLiteral(normalizePassportNo(userKey))}
       or u.phone = ${sqlLiteral(normalizePhone(userKey))}
    limit 1;
  `)
  if (!user) {
    const error = new Error('用户不存在')
    error.statusCode = 404
    error.code = 'user_not_found'
    throw error
  }

  const durationDays = Math.max(1, Math.min(3660, Number(payload.durationDays || payload.duration_days || 30)))
  const subscription = await queryJson(`
    insert into membership_subscriptions (
      user_id, tier, product_id, billing_cycle, status, starts_at, expires_at, auto_renew
    )
    values (
      ${sqlLiteral(user.id)}::uuid,
      ${sqlLiteral(tier)},
      ${sqlLiteral(tier === 'senior' ? 'membership-senior' : 'membership-junior')},
      'manual_grant',
      'active',
      now(),
      now() + (${durationDays}::text || ' days')::interval,
      false
    )
    returning json_build_object(
      'id', id,
      'userId', user_id,
      'tier', tier,
      'billingCycle', billing_cycle,
      'status', status,
      'startsAt', starts_at,
      'expiresAt', expires_at,
      'autoRenew', auto_renew,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `)

  await writeMembershipEvent({
    subscriptionId: subscription.id,
    userId: user.id,
    eventType: 'manual_granted',
    fromTier: user.membershipLevel,
    toTier: tier,
    fromStatus: null,
    toStatus: 'active',
    payload: { durationDays, actor },
  })
  await syncUserMembershipLevel(user.id, 'manual_grant')
  await queueNotificationMessage({
    userId: user.id,
    scene: 'membership_manual_granted',
    templateKey: 'membership_manual_granted',
    dedupeKey: `membership:${subscription.id}:manual_granted`,
    payload: {
      title: `${membershipLabel(tier)}已开通`,
      body: `后台已为你开通会员，有效期至 ${new Date(subscription.expiresAt).toLocaleDateString('zh-CN')}。`,
      actionUrl: '/account/membership',
      tier,
      expiresAt: subscription.expiresAt,
    },
  })
  await auditAdmin(actor, 'membership.manual_grant', 'membership_subscription', subscription.id, subscription)
  return subscription
}

export async function cancelAdminMembershipSubscription(token, subscriptionId, payload = {}) {
  const { actor } = await requireAdmin(token, 'membership_subscription:update')
  const mode = String(payload.mode || payload.action || 'period_end').trim()
  if (!['period_end', 'immediate'].includes(mode)) {
    const error = new Error('会员关闭方式无效')
    error.statusCode = 400
    error.code = 'invalid_membership_cancel_mode'
    throw error
  }
  const current = await queryJson(`
    select json_build_object(
      'id', id,
      'userId', user_id,
      'tier', tier,
      'status', status,
      'expiresAt', expires_at
    )
    from membership_subscriptions
    where id = ${sqlLiteral(subscriptionId)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('会员订阅不存在')
    error.statusCode = 404
    error.code = 'membership_subscription_not_found'
    throw error
  }
  const reason = String(payload.reason || payload.cancelReason || payload.cancel_reason || '').trim()
  const updated = await queryJson(`
    update membership_subscriptions
    set
      status = case
        when ${sqlLiteral(mode)} = 'period_end' then status
        else 'canceled'
      end,
      cancel_requested_at = coalesce(cancel_requested_at, now()),
      cancel_requested_reason = ${sqlLiteral(reason)},
      cancelled_by = ${sqlLiteral(actor)},
      canceled_at = case when ${sqlLiteral(mode)} <> 'period_end' then coalesce(canceled_at, now()) else canceled_at end,
      ended_at = case when ${sqlLiteral(mode)} <> 'period_end' then coalesce(ended_at, now()) else ended_at end,
      cancel_reason = case when ${sqlLiteral(mode)} <> 'period_end' then ${sqlLiteral(reason)} else cancel_reason end,
      updated_at = now()
    where id = ${sqlLiteral(subscriptionId)}::uuid
    returning json_build_object(
      'id', id,
      'userId', user_id,
      'tier', tier,
      'productId', product_id,
      'billingCycle', billing_cycle,
      'status', status,
      'startsAt', starts_at,
      'expiresAt', expires_at,
      'endedAt', ended_at,
      'cancelRequestedAt', cancel_requested_at,
      'cancelRequestedReason', cancel_requested_reason,
      'cancelledBy', cancelled_by,
      'canceledAt', canceled_at,
      'cancelReason', cancel_reason,
      'updatedAt', updated_at
    );
  `)
  await writeMembershipEvent({
    subscriptionId: updated.id,
    userId: updated.userId,
    eventType: mode === 'period_end' ? 'cancel_requested' : 'canceled',
    fromTier: current.tier,
    toTier: updated.tier,
    fromStatus: current.status,
    toStatus: updated.status,
    payload: { reason, mode, actor },
  })
  if (mode !== 'period_end') {
    await syncUserMembershipLevel(updated.userId, mode)
  }
  await auditAdmin(actor, `membership.${mode}`, 'membership_subscription', updated.id, updated)
  return updated
}

function parseMembershipAdjustmentDate(value, fallback, fieldLabel) {
  if (value === undefined || value === null || String(value).trim() === '') return fallback
  const parsed = new Date(value)
  if (Number.isNaN(parsed.getTime())) {
    const error = new Error(`${fieldLabel}无效`)
    error.statusCode = 400
    error.code = 'invalid_membership_date'
    throw error
  }
  return parsed.toISOString()
}

export async function updateAdminMembershipSubscription(token, subscriptionId, payload = {}) {
  const { actor } = await requireAdmin(token, 'membership_subscription:update')
  if (!isUuid(subscriptionId)) {
    const error = new Error('会员订阅 ID 无效')
    error.statusCode = 400
    error.code = 'invalid_membership_subscription_id'
    throw error
  }

  const current = await queryJson(`
    select json_build_object(
      'id', ms.id,
      'userId', ms.user_id,
      'tier', ms.tier,
      'productId', ms.product_id,
      'billingCycle', ms.billing_cycle,
      'status', ms.status,
      'startsAt', ms.starts_at,
      'expiresAt', ms.expires_at,
      'endedAt', ms.ended_at,
      'cancelRequestedAt', ms.cancel_requested_at,
      'cancelRequestedReason', ms.cancel_requested_reason,
      'cancelledBy', ms.cancelled_by,
      'canceledAt', ms.canceled_at,
      'cancelReason', ms.cancel_reason
    )
    from membership_subscriptions ms
    where ms.id = ${sqlLiteral(subscriptionId)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('会员订阅不存在')
    error.statusCode = 404
    error.code = 'membership_subscription_not_found'
    throw error
  }

  const nextTier = String(payload.tier || current.tier || '').trim()
  if (!membershipTiers.has(nextTier)) {
    const error = new Error('会员等级无效')
    error.statusCode = 400
    error.code = 'invalid_membership_tier'
    throw error
  }

  const allowedStatuses = new Set(['active', 'past_due', 'expired', 'canceled', 'refunded'])
  const nextStatus = String(payload.status || current.status || '').trim()
  if (!allowedStatuses.has(nextStatus)) {
    const error = new Error('会员状态无效')
    error.statusCode = 400
    error.code = 'invalid_membership_status'
    throw error
  }

  const nextStartsAt = parseMembershipAdjustmentDate(payload.startsAt ?? payload.starts_at, current.startsAt, '开始时间')
  const nextExpiresAt = parseMembershipAdjustmentDate(payload.expiresAt ?? payload.expires_at, current.expiresAt, '到期时间')
  if (new Date(nextExpiresAt).getTime() <= new Date(nextStartsAt).getTime()) {
    const error = new Error('到期时间必须晚于开始时间')
    error.statusCode = 400
    error.code = 'invalid_membership_period'
    throw error
  }
  if (nextStatus === 'active' && new Date(nextExpiresAt).getTime() <= Date.now()) {
    const error = new Error('有效会员的到期时间必须晚于当前时间')
    error.statusCode = 400
    error.code = 'invalid_active_membership_expiry'
    throw error
  }

  const reason = String(payload.reason || payload.note || payload.adjustReason || payload.adjust_reason || '').trim()
  if (!reason) {
    const error = new Error('请填写调整原因')
    error.statusCode = 400
    error.code = 'membership_adjust_reason_required'
    throw error
  }

  const updated = await queryJson(`
    update membership_subscriptions
    set
      tier = ${sqlLiteral(nextTier)},
      product_id = ${sqlLiteral(nextTier === 'senior' ? 'membership-senior' : 'membership-junior')},
      status = ${sqlLiteral(nextStatus)},
      starts_at = ${sqlLiteral(nextStartsAt)}::timestamptz,
      expires_at = ${sqlLiteral(nextExpiresAt)}::timestamptz,
      ended_at = case
        when ${sqlLiteral(nextStatus)} = 'active' then null
        when ${sqlLiteral(nextStatus)} in ('expired', 'canceled', 'refunded') then coalesce(ended_at, now())
        else ended_at
      end,
      cancel_requested_at = case when ${sqlLiteral(nextStatus)} = 'active' then null else cancel_requested_at end,
      cancel_requested_reason = case when ${sqlLiteral(nextStatus)} = 'active' then '' else cancel_requested_reason end,
      cancelled_by = case
        when ${sqlLiteral(nextStatus)} = 'active' then ''
        when ${sqlLiteral(nextStatus)} in ('canceled', 'refunded') then coalesce(nullif(cancelled_by, ''), ${sqlLiteral(actor)})
        else cancelled_by
      end,
      canceled_at = case
        when ${sqlLiteral(nextStatus)} = 'active' then null
        when ${sqlLiteral(nextStatus)} in ('canceled', 'refunded') then coalesce(canceled_at, now())
        else canceled_at
      end,
      cancel_reason = case
        when ${sqlLiteral(nextStatus)} = 'active' then ''
        when ${sqlLiteral(nextStatus)} in ('canceled', 'refunded') then ${sqlLiteral(reason)}
        else cancel_reason
      end,
      updated_at = now()
    where id = ${sqlLiteral(subscriptionId)}::uuid
    returning json_build_object(
      'id', id,
      'userId', user_id,
      'tier', tier,
      'productId', product_id,
      'billingCycle', billing_cycle,
      'status', status,
      'startsAt', starts_at,
      'expiresAt', expires_at,
      'endedAt', ended_at,
      'autoRenew', auto_renew,
      'sourceOrderId', source_order_id,
      'latestRenewalOrderId', latest_renewal_order_id,
      'cancelRequestedAt', cancel_requested_at,
      'cancelRequestedReason', cancel_requested_reason,
      'cancelledBy', cancelled_by,
      'canceledAt', canceled_at,
      'cancelReason', cancel_reason,
      'updatedAt', updated_at
    );
  `)

  await writeMembershipEvent({
    subscriptionId: updated.id,
    userId: updated.userId,
    eventType: 'admin_adjusted',
    fromTier: current.tier,
    toTier: updated.tier,
    fromStatus: current.status,
    toStatus: updated.status,
    payload: {
      reason,
      actor,
      before: current,
      after: updated,
    },
  })
  await syncUserMembershipLevel(updated.userId, 'admin_adjustment')
  await auditAdmin(actor, 'membership.adjust', 'membership_subscription', updated.id, { reason, before: current, after: updated })
  return updated
}

export async function assignAdminMembershipManager(token, subscriptionId, payload = {}) {
  const { actor } = await requireAdmin(token, 'membership_subscription:update')
  const staffAccountId = payload.staffAccountId || payload.staff_account_id || payload.assignedStaffAccountId || payload.assigned_staff_account_id
  if (!isUuid(staffAccountId)) {
    const error = new Error('请选择有效客户经理')
    error.statusCode = 400
    error.code = 'invalid_membership_manager'
    throw error
  }
  const subscription = await queryJson(`
    select json_build_object('id', id, 'userId', user_id, 'tier', tier, 'status', status)
    from membership_subscriptions
    where id = ${sqlLiteral(subscriptionId)}::uuid
    limit 1;
  `)
  if (!subscription) {
    const error = new Error('会员订阅不存在')
    error.statusCode = 404
    error.code = 'membership_subscription_not_found'
    throw error
  }
  if (subscription.tier !== 'senior') {
    const error = new Error('只有高级会员需要分配客户经理')
    error.statusCode = 409
    error.code = 'membership_manager_requires_senior'
    throw error
  }
  await executeSql(`
    update membership_service_assignments
    set status = 'ended',
        ended_at = now()
    where subscription_id = ${sqlLiteral(subscriptionId)}::uuid
      and assignment_type = 'customer_manager'
      and status = 'active';
  `)
  const updated = await queryJson(`
    insert into membership_service_assignments (
      user_id, subscription_id, assignment_type, assigned_staff_account_id, status, note
    )
    values (
      ${sqlLiteral(subscription.userId)}::uuid,
      ${sqlLiteral(subscriptionId)}::uuid,
      'customer_manager',
      ${sqlLiteral(staffAccountId)}::uuid,
      'active',
      ${sqlLiteral(payload.note || '')}
    )
    returning json_build_object(
      'id', id,
      'userId', user_id,
      'subscriptionId', subscription_id,
      'assignmentType', assignment_type,
      'assignedStaffAccountId', assigned_staff_account_id,
      'status', status,
      'note', note,
      'assignedAt', assigned_at
    );
  `)
  await writeMembershipEvent({
    subscriptionId,
    userId: subscription.userId,
    eventType: 'manager_assigned',
    fromTier: subscription.tier,
    toTier: subscription.tier,
    payload: { staffAccountId, note: payload.note || '', actor },
  })
  await auditAdmin(actor, 'membership.assign_manager', 'membership_subscription', subscriptionId, updated)
  return updated
}

export async function listAdminMembershipBenefits(token) {
  await requireAdminAny(token, ['membership_benefit:read', 'product:read'])
  return queryJson(`
    select coalesce(json_agg(json_build_object(
      'code', code,
      'label', label,
      'groupKey', group_key,
      'benefitType', benefit_type,
      'targetType', target_type,
      'targetRef', target_ref,
      'executionMode', execution_mode,
      'defaultValue', default_value,
      'status', status,
      'sortOrder', sort_order,
      'description', description,
      'createdAt', created_at,
      'updatedAt', updated_at
    ) order by sort_order, code), '[]'::json)
    from membership_benefit_rules;
  `)
}

export async function upsertAdminMembershipBenefit(token, code, payload = {}) {
  const { actor } = await requireAdminAny(token, ['membership_benefit:write', 'product:write'])
  const benefitCode = String(code || payload.code || '').trim()
  if (!/^[a-z][a-z0-9_:-]{2,80}$/.test(benefitCode)) {
    const error = new Error('权益编码必须为 3-80 位小写字母、数字、下划线或冒号')
    error.statusCode = 400
    error.code = 'invalid_membership_benefit_code'
    throw error
  }
  const label = String(payload.label || '').trim()
  if (!label) {
    const error = new Error('权益名称不能为空')
    error.statusCode = 400
    error.code = 'invalid_membership_benefit_label'
    throw error
  }
  const groupKey = String(payload.groupKey || payload.group_key || 'general').trim() || 'general'
  const benefitType = String(payload.benefitType || payload.benefit_type || 'display').trim() || 'display'
  const targetType = String(payload.targetType || payload.target_type || 'global').trim() || 'global'
  const targetRef = String(payload.targetRef || payload.target_ref || '').trim()
  const executionMode = String(payload.executionMode || payload.execution_mode || 'display').trim() || 'display'
  const status = ['active', 'inactive', 'draft'].includes(payload.status) ? payload.status : 'active'
  const sortOrder = Math.max(0, nullableInteger(payload.sortOrder ?? payload.sort_order) ?? 100)
  const description = String(payload.description || '').trim()
  const defaultValue = payload.defaultValue || payload.default_value || {}
  const updated = await queryJson(`
    insert into membership_benefit_rules (
      code, label, group_key, benefit_type, target_type, target_ref, execution_mode,
      default_value, status, sort_order, description
    )
    values (
      ${sqlLiteral(benefitCode)},
      ${sqlLiteral(label)},
      ${sqlLiteral(groupKey)},
      ${sqlLiteral(benefitType)},
      ${sqlLiteral(targetType)},
      ${sqlLiteral(targetRef)},
      ${sqlLiteral(executionMode)},
      ${sqlJsonb(defaultValue)},
      ${sqlLiteral(status)},
      ${sortOrder},
      ${sqlLiteral(description)}
    )
    on conflict (code) do update set
      label = excluded.label,
      group_key = excluded.group_key,
      benefit_type = excluded.benefit_type,
      target_type = excluded.target_type,
      target_ref = excluded.target_ref,
      execution_mode = excluded.execution_mode,
      default_value = excluded.default_value,
      status = excluded.status,
      sort_order = excluded.sort_order,
      description = excluded.description,
      updated_at = now()
    returning json_build_object(
      'code', code,
      'label', label,
      'groupKey', group_key,
      'benefitType', benefit_type,
      'targetType', target_type,
      'targetRef', target_ref,
      'executionMode', execution_mode,
      'defaultValue', default_value,
      'status', status,
      'sortOrder', sort_order,
      'description', description,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `)
  await auditAdmin(actor, 'membership_benefit.upsert', 'membership_benefit_rule', benefitCode, updated)
  return updated
}

export async function updateAdminMembershipPlanBenefits(token, productId, payload = {}) {
  const { actor } = await requireAdminAny(token, ['membership_benefit:write', 'membership_plan:write', 'product:write'])
  const plan = await queryJson(`
    select json_build_object('productId', product_id, 'level', level)
    from membership_plans
    where product_id = ${sqlLiteral(productId)}
    limit 1;
  `)
  if (!plan) {
    const error = new Error('会员方案不存在')
    error.statusCode = 404
    error.code = 'membership_plan_not_found'
    throw error
  }
  const benefits = Array.isArray(payload.benefits) ? payload.benefits : []
  for (const item of benefits) {
    const benefitCode = String(item.benefitCode || item.benefit_code || '').trim()
    if (!benefitCode) continue
    const exists = await queryJson(`
      select json_build_object('code', code)
      from membership_benefit_rules
      where code = ${sqlLiteral(benefitCode)}
      limit 1;
    `)
    if (!exists) continue
    const enabled = item.enabled !== false
    const displayLabel = String(item.displayLabel || item.display_label || '').trim()
    const sortOrder = Math.max(0, nullableInteger(item.sortOrder ?? item.sort_order) ?? 100)
    const status = ['active', 'inactive'].includes(item.status) ? item.status : 'active'
    await executeSql(`
      insert into membership_plan_benefits (
        plan_product_id, benefit_code, enabled, value_json, display_label, sort_order, status
      )
      values (
        ${sqlLiteral(productId)},
        ${sqlLiteral(benefitCode)},
        ${enabled ? 'true' : 'false'},
        ${sqlJsonb(item.value || item.valueJson || item.value_json || {})},
        ${sqlLiteral(displayLabel)},
        ${sortOrder},
        ${sqlLiteral(status)}
      )
      on conflict (plan_product_id, benefit_code) do update set
        enabled = excluded.enabled,
        value_json = excluded.value_json,
        display_label = excluded.display_label,
        sort_order = excluded.sort_order,
        status = excluded.status,
        updated_at = now();
    `)
  }
  const updated = await queryJson(`
    select coalesce(json_agg(json_build_object(
      'benefitCode', pb.benefit_code,
      'enabled', pb.enabled,
      'value', pb.value_json,
      'displayLabel', pb.display_label,
      'sortOrder', pb.sort_order,
      'status', pb.status
    ) order by pb.sort_order, pb.benefit_code), '[]'::json)
    from membership_plan_benefits pb
    where pb.plan_product_id = ${sqlLiteral(productId)};
  `)
  await auditAdmin(actor, 'membership_plan_benefits.update', 'membership_plan', productId, { benefits: updated })
  return { productId, benefits: updated || [] }
}

export async function listAdminMembershipEntitlements(token, filters = {}) {
  await requireAdminAny(token, ['membership_entitlement:read', 'membership_entitlement:update', 'user:read'])
  const search = String(filters.search || '').trim()
  const benefitCode = String(filters.benefitCode || filters.benefit_code || '').trim()
  const status = ['active', 'expired', 'closed'].includes(filters.status) ? filters.status : ''
  return queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', mea.id,
      'userId', mea.user_id,
      'userName', u.display_name,
      'userPhone', u.phone,
      'passportNo', u.passport_no,
      'subscriptionId', mea.subscription_id,
      'benefitCode', mea.benefit_code,
      'periodKey', mea.period_key,
      'quotaTotal', mea.quota_total,
      'quotaUsed', mea.quota_used,
      'quotaRemaining', greatest(0, mea.quota_total - mea.quota_used),
      'validFrom', mea.valid_from,
      'validTo', mea.valid_to,
      'status', mea.status,
      'metadata', mea.metadata,
      'updatedAt', mea.updated_at,
      'ledger', coalesce((
        select json_agg(json_build_object(
          'id', mel.id,
          'action', mel.action,
          'delta', mel.delta,
          'refType', mel.ref_type,
          'refId', mel.ref_id,
          'beforeValue', mel.before_value,
          'afterValue', mel.after_value,
          'payload', mel.payload,
          'createdAt', mel.created_at
        ) order by mel.created_at desc)
        from membership_entitlement_ledger mel
        where mel.account_id = mea.id
        limit 20
      ), '[]'::json)
    ) order by mea.valid_to desc, mea.updated_at desc), '[]'::json)
    from membership_entitlement_accounts mea
    join users u on u.id = mea.user_id
    where 1 = 1
      ${benefitCode ? `and mea.benefit_code = ${sqlLiteral(benefitCode)}` : ''}
      ${status ? `and mea.status = ${sqlLiteral(status)}` : ''}
      ${search ? `and (
        u.display_name ilike ${sqlLiteral(`%${search}%`)}
        or u.phone ilike ${sqlLiteral(`%${search}%`)}
        or u.passport_no ilike ${sqlLiteral(`%${search}%`)}
        or mea.user_id::text = ${sqlLiteral(search)}
      )` : ''}
    limit 200;
  `)
}

export async function adjustAdminMembershipEntitlement(token, accountId, payload = {}) {
  const { actor } = await requireAdminAny(token, ['membership_entitlement:update', 'user:write'])
  const delta = nullableInteger(payload.delta)
  const quotaTotal = nullableInteger(payload.quotaTotal ?? payload.quota_total)
  if (delta === null && quotaTotal === null) {
    const error = new Error('请填写额度增减值或新的总额度')
    error.statusCode = 400
    error.code = 'invalid_membership_entitlement_adjustment'
    throw error
  }
  const current = await queryJson(`
    select json_build_object(
      'id', id,
      'userId', user_id,
      'benefitCode', benefit_code,
      'quotaTotal', quota_total,
      'quotaUsed', quota_used,
      'status', status
    )
    from membership_entitlement_accounts
    where id = ${sqlLiteral(accountId)}::uuid
    limit 1;
  `)
  if (!current) {
    const error = new Error('会员额度账户不存在')
    error.statusCode = 404
    error.code = 'membership_entitlement_not_found'
    throw error
  }
  const beforeRemaining = Math.max(0, Number(current.quotaTotal || 0) - Number(current.quotaUsed || 0))
  const nextTotal = Math.max(Number(current.quotaUsed || 0), quotaTotal ?? (Number(current.quotaTotal || 0) + Number(delta || 0)))
  const afterRemaining = Math.max(0, nextTotal - Number(current.quotaUsed || 0))
  const note = String(payload.note || '').trim()
  const updated = await queryJson(`
    update membership_entitlement_accounts
    set quota_total = ${nextTotal},
        metadata = metadata || ${sqlJsonb({ lastAdjustmentNote: note, lastAdjustedBy: actor })},
        updated_at = now()
    where id = ${sqlLiteral(accountId)}::uuid
    returning json_build_object(
      'id', id,
      'userId', user_id,
      'subscriptionId', subscription_id,
      'benefitCode', benefit_code,
      'periodKey', period_key,
      'quotaTotal', quota_total,
      'quotaUsed', quota_used,
      'quotaRemaining', greatest(0, quota_total - quota_used),
      'validFrom', valid_from,
      'validTo', valid_to,
      'status', status,
      'metadata', metadata,
      'updatedAt', updated_at
    );
  `)
  await executeSql(`
    insert into membership_entitlement_ledger (
      account_id, user_id, benefit_code, action, delta, ref_type, ref_id,
      before_value, after_value, payload
    )
    values (
      ${sqlLiteral(accountId)}::uuid,
      ${sqlLiteral(current.userId)}::uuid,
      ${sqlLiteral(current.benefitCode)},
      'admin_adjust',
      ${afterRemaining - beforeRemaining},
      'admin',
      ${sqlLiteral(actor)},
      ${beforeRemaining},
      ${afterRemaining},
      ${sqlJsonb({ note, quotaTotal, delta })}
    );
  `)
  await auditAdmin(actor, 'membership_entitlement.adjust', 'membership_entitlement_account', accountId, {
    beforeRemaining,
    afterRemaining,
    note,
  })
  return updated
}

export async function listAdminMembershipContentDeliveries(token, filters = {}) {
  await requireAdminAny(token, ['membership_content:write', 'product:read'])
  const status = ['draft', 'published', 'offline'].includes(filters.status) ? filters.status : ''
  return queryJson(`
    select coalesce(json_agg(json_build_object(
      'id', id,
      'title', title,
      'contentType', content_type,
      'eligibleTiers', eligible_tiers,
      'fileId', file_id,
      'href', href,
      'periodKey', period_key,
      'status', status,
      'publishedAt', published_at,
      'createdAt', created_at,
      'updatedAt', updated_at
    ) order by period_key desc, updated_at desc), '[]'::json)
    from membership_content_deliveries
    where 1 = 1
      ${status ? `and status = ${sqlLiteral(status)}` : ''}
    limit 200;
  `)
}

export async function upsertAdminMembershipContentDelivery(token, id, payload = {}) {
  const { actor } = await requireAdminAny(token, ['membership_content:write', 'product:write'])
  const title = String(payload.title || '').trim()
  if (!title) {
    const error = new Error('会员内容标题不能为空')
    error.statusCode = 400
    error.code = 'invalid_membership_content_title'
    throw error
  }
  const contentType = String(payload.contentType || payload.content_type || 'monthly_report').trim() || 'monthly_report'
  const eligibleTiers = compactStringArray(payload.eligibleTiers || payload.eligible_tiers || ['junior', 'senior'])
    .filter((item) => ['junior', 'senior'].includes(item))
  const status = ['draft', 'published', 'offline'].includes(payload.status) ? payload.status : 'draft'
  const periodKey = String(payload.periodKey || payload.period_key || new Date().toISOString().slice(0, 7)).trim()
  const fileIdValue = String(payload.fileId || payload.file_id || '').trim()
  if (status === 'published' && contentType === 'monthly_report' && !isUuid(fileIdValue)) {
    const error = new Error('会员报告发布前必须先上传报告文件')
    error.statusCode = 400
    error.code = 'membership_content_file_required'
    throw error
  }
  const fileId = isUuid(fileIdValue) ? `${sqlLiteral(fileIdValue)}::uuid` : 'null'
  const href = String(payload.href || '').trim()
  const rowId = isUuid(id || payload.id) ? String(id || payload.id) : ''
  const sql = rowId ? `
    update membership_content_deliveries
    set title = ${sqlLiteral(title)},
        content_type = ${sqlLiteral(contentType)},
        eligible_tiers = ${sqlTextArray(eligibleTiers.length ? eligibleTiers : ['junior', 'senior'])},
        file_id = ${fileId},
        href = ${sqlLiteral(href)},
        period_key = ${sqlLiteral(periodKey)},
        status = ${sqlLiteral(status)},
        published_at = case when ${sqlLiteral(status)} = 'published' then coalesce(published_at, now()) else published_at end,
        updated_at = now()
    where id = ${sqlLiteral(rowId)}::uuid
    returning json_build_object(
      'id', id,
      'title', title,
      'contentType', content_type,
      'eligibleTiers', eligible_tiers,
      'fileId', file_id,
      'href', href,
      'periodKey', period_key,
      'status', status,
      'publishedAt', published_at,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  ` : `
    insert into membership_content_deliveries (
      title, content_type, eligible_tiers, file_id, href, period_key, status, published_at
    )
    values (
      ${sqlLiteral(title)},
      ${sqlLiteral(contentType)},
      ${sqlTextArray(eligibleTiers.length ? eligibleTiers : ['junior', 'senior'])},
      ${fileId},
      ${sqlLiteral(href)},
      ${sqlLiteral(periodKey)},
      ${sqlLiteral(status)},
      case when ${sqlLiteral(status)} = 'published' then now() else null end
    )
    returning json_build_object(
      'id', id,
      'title', title,
      'contentType', content_type,
      'eligibleTiers', eligible_tiers,
      'fileId', file_id,
      'href', href,
      'periodKey', period_key,
      'status', status,
      'publishedAt', published_at,
      'createdAt', created_at,
      'updatedAt', updated_at
    );
  `
  const updated = await queryJson(sql)
  if (!updated) {
    const error = new Error('会员内容不存在')
    error.statusCode = 404
    error.code = 'membership_content_not_found'
    throw error
  }
  await auditAdmin(actor, 'membership_content.upsert', 'membership_content_delivery', updated.id, updated)
  return updated
}

export async function getAdminMembershipCatalog(token) {
  await requireAdmin(token, 'product:read')
  return getMembershipCatalog()
}

export async function listAdminNotifications(token, filters = {}) {
  await requireAdmin(token, 'notification:read')
  const status = ['queued', 'sent', 'failed', 'skipped'].includes(filters.status) ? filters.status : ''
  const scene = String(filters.scene || '').trim()
  const category = String(filters.category || '').trim()
  const channel = notificationChannels.has(filters.channel) ? filters.channel : ''
  const batchId = isUuid(filters.batchId || filters.batch_id) ? String(filters.batchId || filters.batch_id) : ''
  const page = Math.max(1, toSafeInteger(filters.page, 1))
  const pageSize = Math.min(100, Math.max(1, toSafeInteger(filters.pageSize ?? filters.page_size, 30)))
  const offset = (page - 1) * pageSize
  return queryJson(`
    with filtered as (
      select nm.*, u.display_name, u.phone, u.passport_no
      from notification_messages nm
      join users u on u.id = nm.user_id
      where 1 = 1
        ${status ? `and nm.status = ${sqlLiteral(status)}` : ''}
        ${scene ? `and nm.scene = ${sqlLiteral(scene)}` : ''}
        ${category ? `and coalesce(nm.payload_json->>'category', '') = ${sqlLiteral(category)}` : ''}
        ${channel ? `and nm.channel = ${sqlLiteral(channel)}` : ''}
        ${batchId ? `and nm.batch_id = ${sqlLiteral(batchId)}::uuid` : ''}
    )
    select json_build_object(
      'items', coalesce((
        select json_agg(json_build_object(
          'id', id,
          'batchId', batch_id,
          'userId', user_id,
          'userName', display_name,
          'userPhone', phone,
          'passportNo', passport_no,
          'scene', scene,
          'channel', channel,
          'templateKey', template_key,
          'title', coalesce(payload_json->>'title', ''),
          'body', coalesce(payload_json->>'body', ''),
          'actionUrl', coalesce(payload_json->>'actionUrl', ''),
          'category', coalesce(payload_json->>'category', ''),
          'payload', payload_json,
          'status', status,
          'priority', priority,
          'scheduledAt', scheduled_at,
          'sentAt', sent_at,
          'readAt', read_at,
          'failedReason', failed_reason,
          'createdAt', created_at,
          'updatedAt', updated_at
        ) order by created_at desc)
        from (
          select *
          from filtered
          order by created_at desc
          limit ${pageSize} offset ${offset}
        ) page_items
      ), '[]'::json),
      'page', ${page},
      'pageSize', ${pageSize},
      'total', (select count(*)::int from filtered)
    );
  `)
}

function normalizeProductPayload(payload = {}, creating) {
  const id = payload.id || payload.slug
  const slug = payload.slug || payload.id
  const type = payload.type
  const status = payload.status || (creating ? 'draft' : 'published')
  const priceAmount = Number(payload.priceAmount ?? payload.price_amount ?? 0)

  if (!id || !slug || !payload.title) {
    const error = new Error('商品必须包含 id、slug 和 title')
    error.statusCode = 400
    error.code = 'invalid_product'
    throw error
  }
  if (!productTypes.has(type)) {
    const error = new Error('商品类型无效')
    error.statusCode = 400
    error.code = 'invalid_product_type'
    throw error
  }
  if (!productStatuses.has(status)) {
    const error = new Error('商品状态无效')
    error.statusCode = 400
    error.code = 'invalid_product_status'
    throw error
  }
  if (!Number.isInteger(priceAmount) || priceAmount < 0) {
    const error = new Error('商品价格必须是非负整数分')
    error.statusCode = 400
    error.code = 'invalid_price'
    throw error
  }

  return {
    id,
    slug,
    type,
    title: payload.title,
    summary: payload.summary || '',
    priceAmount,
    purchaseFlow: payload.purchaseFlow || payload.purchase_flow || defaultPurchaseFlow(type),
    fulfillmentFlow: payload.fulfillmentFlow || payload.fulfillment_flow || defaultFulfillmentFlow(type),
    status,
  }
}

function normalizeCouponPayload(payload = {}, creating) {
  const code = String(payload.code || '').trim().toUpperCase()
  const name = String(payload.name || '').trim()
  const description = String(payload.description || '').trim()
  const discountType = payload.discountType || payload.discount_type || 'amount'
  const discountValue = Number(payload.discountValue ?? payload.discount_value ?? 0)
  const maxDiscountAmount = nullableInteger(payload.maxDiscountAmount ?? payload.max_discount_amount)
  const minOrderAmount = Number(payload.minOrderAmount ?? payload.min_order_amount ?? 0)
  const productScope = normalizeCouponProductScope(payload.productScope || payload.product_scope)
  const userScope = normalizeCouponUserScope(payload.userScope || payload.user_scope)
  const usageLimits = normalizeCouponUsageLimits(payload.usageLimits || payload.usage_limits)
  const stackingPolicy = normalizeCouponStackingPolicy(payload.stackingPolicy || payload.stacking_policy)
  const budgetAmount = nullableInteger(payload.budgetAmount ?? payload.budget_amount)
  const totalQuantity = Number(payload.totalQuantity ?? payload.total_quantity ?? 0)
  const startsAt = payload.startsAt || payload.starts_at || new Date().toISOString()
  const endsAt = payload.endsAt ?? payload.ends_at ?? null
  const status = payload.status || (creating ? 'active' : 'active')

  if (!/^[A-Z0-9_-]{3,32}$/.test(code) || !name) {
    const error = new Error('优惠码必须包含 3-32 位 code 和 name')
    error.statusCode = 400
    error.code = 'invalid_coupon'
    throw error
  }
  if (!discountTypes.has(discountType)) {
    const error = new Error('优惠类型无效')
    error.statusCode = 400
    error.code = 'invalid_discount_type'
    throw error
  }
  if (!couponStatuses.has(status)) {
    const error = new Error('优惠码状态无效')
    error.statusCode = 400
    error.code = 'invalid_coupon_status'
    throw error
  }
  if (!Number.isInteger(discountValue) || discountValue <= 0) {
    const error = new Error('优惠金额或比例必须大于 0')
    error.statusCode = 400
    error.code = 'invalid_discount_value'
    throw error
  }
  if (discountType === 'percent' && discountValue > 100) {
    const error = new Error('百分比优惠不能超过 100')
    error.statusCode = 400
    error.code = 'invalid_discount_value'
    throw error
  }
  if (!Number.isInteger(minOrderAmount) || minOrderAmount < 0) {
    const error = new Error('最低订单金额必须是非负整数分')
    error.statusCode = 400
    error.code = 'invalid_min_order_amount'
    throw error
  }
  if (maxDiscountAmount !== null && (!Number.isInteger(maxDiscountAmount) || maxDiscountAmount < 0)) {
    const error = new Error('优惠封顶金额必须是非负整数分')
    error.statusCode = 400
    error.code = 'invalid_max_discount_amount'
    throw error
  }
  if (budgetAmount !== null && (!Number.isInteger(budgetAmount) || budgetAmount < 0)) {
    const error = new Error('优惠预算必须是非负整数分')
    error.statusCode = 400
    error.code = 'invalid_budget_amount'
    throw error
  }
  if (!Number.isInteger(totalQuantity) || totalQuantity <= 0) {
    const error = new Error('优惠码数量必须是正整数')
    error.statusCode = 400
    error.code = 'invalid_coupon_quantity'
    throw error
  }

  return {
    code,
    name,
    description,
    discountType,
    discountValue,
    maxDiscountAmount,
    minOrderAmount,
    productScope,
    userScope,
    usageLimits,
    stackingPolicy,
    budgetAmount,
    totalQuantity,
    startsAt,
    endsAt,
    status,
  }
}

function nullableInteger(value) {
  if (value === null || value === undefined || value === '') return null
  const numberValue = Number(value)
  if (!Number.isFinite(numberValue)) return null
  return Math.round(numberValue)
}

function compactStringArray(value) {
  const items = Array.isArray(value)
    ? value
    : String(value || '').split(/[,\n，；;]+/)
  return Array.from(new Set(items.map((item) => String(item).trim()).filter(Boolean)))
}

function normalizeCouponProductScope(value = {}) {
  const scope = value && typeof value === 'object' ? value : {}
  const mode = scope.mode === 'include' ? 'include' : 'all'
  const productTypesValue = compactStringArray(scope.productTypes || scope.product_types)
    .filter((item) => productTypes.has(item))
  return {
    mode,
    productTypes: productTypesValue,
    productIds: compactStringArray(scope.productIds || scope.product_ids),
    excludeProductIds: compactStringArray(scope.excludeProductIds || scope.exclude_product_ids),
  }
}

function normalizeCouponUserScope(value = {}) {
  const scope = value && typeof value === 'object' ? value : {}
  return {
    membershipLevels: compactStringArray(scope.membershipLevels || scope.membership_levels)
      .filter((item) => ['registered', 'junior', 'senior'].includes(item)),
    passportNos: compactStringArray(scope.passportNos || scope.passport_nos).map(normalizePassportNo),
    userIds: compactStringArray(scope.userIds || scope.user_ids).filter(isUuid),
    firstOrderOnly: Boolean(scope.firstOrderOnly || scope.first_order_only),
    newUserOnly: Boolean(scope.newUserOnly || scope.new_user_only),
  }
}

function normalizeCouponUsageLimits(value = {}) {
  const limits = value && typeof value === 'object' ? value : {}
  return {
    perUser: Math.max(0, Math.round(Number(limits.perUser ?? limits.per_user ?? 0) || 0)),
    perDay: Math.max(0, Math.round(Number(limits.perDay ?? limits.per_day ?? 0) || 0)),
  }
}

function normalizeCouponStackingPolicy(value = {}) {
  const policy = value && typeof value === 'object' ? value : {}
  return {
    allowMemberDiscount: policy.allowMemberDiscount !== false && policy.allow_member_discount !== false,
  }
}

function normalizeNewsPayload(payload = {}, creating) {
  const title = String(payload.title || '').trim()
  const slug = String(payload.slug || slugify(title)).trim()
  const categoryCode = String(payload.categoryCode ?? payload.category_code ?? '').trim()
  const category = String(payload.category || categoryCode || 'institute_news').trim()
  const status = payload.status || (creating ? 'draft' : 'draft')
  const publishedAt = payload.publishedAt ?? payload.published_at ?? (status === 'published' ? new Date().toISOString() : null)
  const content = String(payload.content || '').trim()
  const coverUrl = payload.coverUrl ?? payload.cover_url ?? null

  if (!slug || !title) {
    const error = new Error('资讯必须包含 slug 和标题')
    error.statusCode = 400
    error.code = 'invalid_news_article'
    throw error
  }
  if (!/^[a-z0-9][a-z0-9-]{2,96}$/.test(slug)) {
    const error = new Error('资讯 slug 只能包含小写字母、数字和短横线')
    error.statusCode = 400
    error.code = 'invalid_news_slug'
    throw error
  }
  if (!newsStatuses.has(status)) {
    const error = new Error('资讯状态无效')
    error.statusCode = 400
    error.code = 'invalid_news_status'
    throw error
  }
  if (String(coverUrl || '').startsWith('data:') || /<img[^>]+src=["']data:/i.test(content)) {
    const error = new Error('资讯图片请先通过后台上传文件后再保存')
    error.statusCode = 400
    error.code = 'inline_news_image_not_allowed'
    throw error
  }

  return {
    slug,
    title,
    categoryCode,
    category,
    summary: String(payload.summary || '').trim(),
    content,
    authorName: String(payload.authorName ?? payload.author_name ?? '大连市大数据研究院').trim() || '大连市大数据研究院',
    coverUrl: normalizeManagedAssetUrl(coverUrl || '', '资讯封面'),
    status,
    featuredPosition: normalizeFeaturedPosition(payload.featuredPosition ?? payload.featured_position),
    relatedArticleIds: normalizeRelatedArticleIds(payload.relatedArticleIds ?? payload.related_article_ids),
    publishedAt,
  }
}

function normalizeFeaturedPosition(value) {
  const position = Number(value || 0)
  return position >= 1 && position <= 3 ? position : null
}

function normalizeRelatedArticleIds(value) {
  const items = Array.isArray(value) ? value : String(value || '').split(',')
  return [...new Set(items.map((item) => String(item || '').trim()).filter(Boolean))].slice(0, 3)
}

async function ensureNewsFeaturedSlot(position, currentId) {
  if (!position) return
  const existing = await queryJson(`
    select ${newsArticleJson('n')}
    from news_articles n
    where n.featured_position = ${Number(position)}
      and (${currentId ? `n.id <> ${sqlLiteral(currentId)}::uuid` : 'true'})
    limit 1;
  `)
  if (existing) {
    const error = new Error(`首页精选第 ${position} 位已被占用`)
    error.statusCode = 409
    error.code = 'news_featured_slot_taken'
    throw error
  }
}

async function refreshNewsFeaturedSnapshotIfComplete() {
  const featured = await queryJson(`
    select coalesce(json_agg(json_build_object('id', n.id::text) order by n.featured_position), '[]'::json)
    from news_articles n
    where n.status = 'published'
      and n.featured_position between 1 and 3;
  `)
  if (!Array.isArray(featured) || featured.length !== 3) return
  await executeSql(`
    insert into news_featured_snapshots (id, article_ids, updated_at)
    values ('home', ${sqlTextArray(featured.map((item) => item.id))}, now())
    on conflict (id) do update set
      article_ids = excluded.article_ids,
      updated_at = excluded.updated_at;
  `)
}

function normalizeExpertDirectionPayload(payload = {}, creating = false) {
  const name = String(payload.name || '').trim()
  const groupName = String(payload.groupName || payload.group_name || payload.directionType || payload.direction_type || payload.type || '').trim()
  const directionType = String(payload.directionType || payload.direction_type || payload.type || groupName).trim()
  const description = String(payload.description || '').trim()
  const status = payload.status || 'active'
  const sortOrder = Number(payload.sortOrder ?? payload.sort_order ?? 0)
  const id = String(payload.id || `direction-${randomUUID()}`).trim()

  if (creating && !id) {
    const error = new Error('专家方向缺少编号')
    error.statusCode = 400
    error.code = 'invalid_expert_direction_id'
    throw error
  }
  if (!name || !groupName) {
    const error = new Error('专家方向缺少名称或类型')
    error.statusCode = 400
    error.code = 'invalid_expert_direction'
    throw error
  }
  if (!expertDirectionStatuses.has(status)) {
    const error = new Error('专家方向状态无效')
    error.statusCode = 400
    error.code = 'invalid_expert_direction_status'
    throw error
  }

  return {
    id,
    groupName,
    directionType: directionType || groupName,
    name,
    description,
    sortOrder: Number.isInteger(sortOrder) ? sortOrder : 0,
    status,
  }
}

function normalizeDirectionList(value) {
  const raw = Array.isArray(value)
    ? value
    : String(value || '')
      .split(/[，,；;\n]+/)
  return Array.from(new Set(raw.map((item) => String(item).trim()).filter(Boolean)))
}

function requireExpertDirections(directions) {
  if (!directions.length) {
    const error = new Error('请选择专家方向')
    error.statusCode = 400
    error.code = 'expert_directions_required'
    throw error
  }
  if (directions.length > 3) {
    const error = new Error('专家方向最多选择 3 个')
    error.statusCode = 400
    error.code = 'expert_directions_limit'
    throw error
  }
}

function normalizeExpertApplicationPayload(payload = {}, user) {
  const directions = normalizeDirectionList(payload.directions || payload.expertDirections || payload.expert_directions)
  requireExpertDirections(directions)
  const name = String(payload.name || user.displayName || '').trim()
  const contactPhone = String(payload.contactPhone || payload.contact_phone || user.phone || '').trim()
  const organization = String(payload.organization || user.organization || '').trim()
  const title = String(payload.title || payload.positionTitle || payload.position_title || '').trim()
  if (!name || !contactPhone || !organization || !title) {
    const error = new Error('专家申请缺少姓名、电话、所属机构或职务职称')
    error.statusCode = 400
    error.code = 'invalid_expert_application'
    throw error
  }
  return {
    name,
    contactPhone,
    organization,
    title,
    directions,
    photoFileId: isUuid(payload.photoFileId || payload.photo_file_id) ? String(payload.photoFileId || payload.photo_file_id) : null,
    photoUrl: String(payload.photoUrl || payload.photo_url || '').trim(),
    projectExperience: String(payload.projectExperience || payload.project_experience || '').trim(),
    representativeAchievements: String(payload.representativeAchievements || payload.representative_achievements || '').trim(),
    homepageIntro: String(payload.homepageIntro || payload.homepage_intro || payload.bio || '').trim(),
    attachmentFileIds: Array.isArray(payload.attachmentFileIds || payload.attachment_file_ids)
      ? (payload.attachmentFileIds || payload.attachment_file_ids).filter(isUuid)
      : [],
  }
}

function normalizeExpertPayload(payload = {}, creating) {
  const name = String(payload.name || '').trim()
  const slug = String(payload.slug || slugify(name)).trim()
  const status = payload.status || (creating ? 'draft' : 'draft')
  const availabilityStatus = payload.availabilityStatus || payload.availability_status || 'next-week'
  const priceAmount = Number(payload.priceAmount ?? payload.price_amount ?? 0)
  const shareRatio = Number(payload.shareRatio ?? payload.share_ratio ?? 80)
  const rating = Number(payload.rating ?? 5)
  const consultationCount = Number(payload.consultationCount ?? payload.consultation_count ?? 0)
  const directions = normalizeDirectionList(payload.directions || payload.expertDirections || payload.expert_directions || payload.specialties)
  const specialties = directions.length
    ? directions
    : Array.isArray(payload.specialties)
      ? payload.specialties.map((item) => String(item).trim()).filter(Boolean)
      : String(payload.specialties || '').split(/[，,]/).map((item) => item.trim()).filter(Boolean)

  if (!slug || !name) {
    const error = new Error('专家必须包含 slug 和姓名')
    error.statusCode = 400
    error.code = 'invalid_expert'
    throw error
  }
  if (!/^[a-z0-9][a-z0-9-]{2,96}$/.test(slug)) {
    const error = new Error('专家 slug 只能包含小写字母、数字和短横线')
    error.statusCode = 400
    error.code = 'invalid_expert_slug'
    throw error
  }
  if (!expertStatuses.has(status)) {
    const error = new Error('专家状态无效')
    error.statusCode = 400
    error.code = 'invalid_expert_status'
    throw error
  }
  if (!expertAvailabilityStatuses.has(availabilityStatus)) {
    const error = new Error('专家可约状态无效')
    error.statusCode = 400
    error.code = 'invalid_expert_availability'
    throw error
  }
  if (!Number.isInteger(priceAmount) || priceAmount < 0) {
    const error = new Error('专家价格必须是非负整数分')
    error.statusCode = 400
    error.code = 'invalid_expert_price'
    throw error
  }
  if (!Number.isInteger(shareRatio) || shareRatio < 0 || shareRatio > 100) {
    const error = new Error('专家分成比例必须在 0-100 之间')
    error.statusCode = 400
    error.code = 'invalid_expert_share_ratio'
    throw error
  }

  return {
    userId: payload.userId || payload.user_id || null,
    passportNo: String(payload.passportNo || payload.passport_no || '').trim(),
    slug,
    name,
    contactPhone: String(payload.contactPhone || payload.contact_phone || '').trim(),
    title: String(payload.title || '').trim(),
    organization: String(payload.organization || '').trim(),
    bio: String(payload.bio || payload.homepageIntro || payload.homepage_intro || '').trim(),
    specialties,
    directions,
    projectExperience: String(payload.projectExperience || payload.project_experience || '').trim(),
    representativeAchievements: String(payload.representativeAchievements || payload.representative_achievements || '').trim(),
    homepageIntro: String(payload.homepageIntro || payload.homepage_intro || payload.bio || '').trim(),
    priceAmount,
    shareRatio,
    acceptingOrders: payload.acceptingOrders ?? payload.accepting_orders ?? true,
    availabilityStatus,
    rating: Number.isFinite(rating) ? rating : 5,
    consultationCount: Number.isInteger(consultationCount) && consultationCount >= 0 ? consultationCount : 0,
    photoUrl: payload.photoUrl ?? payload.photo_url ?? null,
    status,
  }
}

async function insertExpertProfile(expert) {
  return queryJson(`
    insert into expert_profiles (
      user_id, passport_no, slug, name, contact_phone, title, organization, bio,
      specialties, directions, project_experience, representative_achievements,
      homepage_intro, price_amount, share_ratio, accepting_orders,
      availability_status, rating, consultation_count, photo_url, status
    )
    values (
      ${expert.userId ? `${sqlLiteral(expert.userId)}::uuid` : 'null'},
      ${sqlLiteral(expert.passportNo || '')},
      ${sqlLiteral(expert.slug)},
      ${sqlLiteral(expert.name)},
      ${sqlLiteral(expert.contactPhone || '')},
      ${sqlLiteral(expert.title || '')},
      ${sqlLiteral(expert.organization || '')},
      ${sqlLiteral(expert.bio || expert.homepageIntro || '')},
      ${sqlTextArray(expert.specialties || expert.directions || [])},
      ${sqlTextArray(expert.directions || expert.specialties || [])},
      ${sqlLiteral(expert.projectExperience || '')},
      ${sqlLiteral(expert.representativeAchievements || '')},
      ${sqlLiteral(expert.homepageIntro || expert.bio || '')},
      ${expert.priceAmount || 0},
      ${expert.shareRatio ?? 80},
      ${expert.acceptingOrders === false ? 'false' : 'true'},
      ${sqlLiteral(expert.availabilityStatus || 'next-week')},
      ${expert.rating || 5},
      ${expert.consultationCount || 0},
      ${sqlLiteral(expert.photoUrl || null)},
      ${sqlLiteral(expert.status || 'draft')}
    )
    returning ${expertProfileJson('expert_profiles')};
  `)
}

async function updateExpertProfileById(expertId, payload = {}) {
  const expert = normalizeExpertPayload(payload, false)
  const updated = await queryJson(`
    update expert_profiles
    set
      user_id = coalesce(${expert.userId ? `${sqlLiteral(expert.userId)}::uuid` : 'null'}, user_id),
      passport_no = coalesce(nullif(${sqlLiteral(expert.passportNo || '')}, ''), passport_no),
      slug = coalesce(${sqlLiteral(expert.slug)}, slug),
      name = coalesce(${sqlLiteral(expert.name)}, name),
      contact_phone = coalesce(${sqlLiteral(expert.contactPhone)}, contact_phone),
      title = coalesce(${sqlLiteral(expert.title)}, title),
      organization = coalesce(${sqlLiteral(expert.organization)}, organization),
      bio = coalesce(${sqlLiteral(expert.bio)}, bio),
      specialties = ${sqlTextArray(expert.specialties)},
      directions = ${sqlTextArray(expert.directions)},
      project_experience = coalesce(${sqlLiteral(expert.projectExperience)}, project_experience),
      representative_achievements = coalesce(${sqlLiteral(expert.representativeAchievements)}, representative_achievements),
      homepage_intro = coalesce(${sqlLiteral(expert.homepageIntro)}, homepage_intro),
      price_amount = ${expert.priceAmount},
      share_ratio = ${expert.shareRatio},
      accepting_orders = ${expert.acceptingOrders === false ? 'false' : 'true'},
      availability_status = ${sqlLiteral(expert.availabilityStatus)},
      photo_url = coalesce(${sqlLiteral(expert.photoUrl)}, photo_url),
      status = ${sqlLiteral(expert.status)},
      updated_at = now()
    where id = ${sqlLiteral(expertId)}::uuid
      and deleted_at is null
    returning ${expertProfileJson('expert_profiles')};
  `)
  if (!updated) {
    const error = new Error('专家不存在')
    error.statusCode = 404
    error.code = 'expert_not_found'
    throw error
  }
  return updated
}

async function seedExpertDefaultSlots(expertId) {
  await executeSql(`
    insert into expert_schedule_slots (expert_id, starts_at, ends_at, status, note)
    select
      ${sqlLiteral(expertId)}::uuid,
      date_trunc('day', now()) + make_interval(days => day_offset) + start_offset,
      date_trunc('day', now()) + make_interval(days => day_offset) + start_offset + interval '1 hour',
      'open',
      '系统初始化时段'
    from generate_series(0, 6) as day_offset
    cross join (values (interval '10 hours'), (interval '14 hours')) as slots(start_offset)
    on conflict (expert_id, starts_at) do nothing;
  `)
}

async function getExpertApplicationById(applicationId) {
  return queryJson(`
    select ${expertApplicationJson('ea', 'u')}
    from expert_applications ea
    join users u on u.id = ea.user_id
    where ea.id = ${sqlLiteral(applicationId)}::uuid
    limit 1;
  `)
}

async function getExpertBookingById(bookingId) {
  return queryJson(`
    select ${expertBookingJson('b', 'e', 'u')}
    from expert_bookings b
    join expert_profiles e on e.id = b.expert_id
    join users u on u.id = b.user_id
    where b.id = ${sqlLiteral(bookingId)}::uuid
    limit 1;
  `)
}

async function getExpertWithdrawalById(withdrawalId) {
  return queryJson(`
    select ${expertWithdrawalJson('w', 'e', 'u')}
    from expert_withdrawals w
    join expert_profiles e on e.id = w.expert_id
    join users u on u.id = w.user_id
    where w.id = ${sqlLiteral(withdrawalId)}::uuid
    limit 1;
  `)
}

async function getUserByPassportNo(passportNo) {
  if (!passportNo) return null
  return queryJson(`
    select ${userJson('u')}
    from users u
    where u.passport_no = ${sqlLiteral(passportNo)}
    limit 1;
  `)
}

async function createUserForManualExpert(payload = {}) {
  const phone = String(payload.phone || payload.contactPhone || payload.contact_phone || '').trim()
  const displayName = String(payload.displayName || payload.name || '').trim()
  if (!phone || !/^1\d{10}$/.test(phone)) {
    const error = new Error('创建专家账号需要有效手机号')
    error.statusCode = 400
    error.code = 'invalid_expert_user_phone'
    throw error
  }
  return queryJson(`
    insert into users (phone, display_name, organization, onboarding_completed)
    values (
      ${sqlLiteral(phone)},
      ${sqlLiteral(displayName)},
      ${sqlLiteral(payload.organization || '')},
      true
    )
    on conflict (phone) do update set
      display_name = coalesce(nullif(${sqlLiteral(displayName)}, ''), users.display_name),
      organization = coalesce(nullif(${sqlLiteral(payload.organization || '')}, ''), users.organization),
      updated_at = now()
    returning ${userJson('users')};
  `)
}

function slugify(value) {
  const slug = String(value || '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 80)
  return slug || `news-${Date.now()}`
}

function normalizeConsultingPayload(payload = {}, user) {
  const company = String(payload.company || user.organization || '未填写机构').trim()
  const contactName = String(payload.contactName || payload.contact_name || user.displayName || '平台用户').trim()
  const contactPhone = String(payload.contactPhone || payload.contact_phone || user.phone || '').trim()
  const direction = String(payload.direction || '数字化规划').trim()
  const currentSituation = String(payload.currentSituation || payload.current_situation || '').trim()
  const requirement = String(payload.requirement || '').trim()

  if (!company || !contactName || !contactPhone || !direction || !currentSituation || !requirement) {
    const error = new Error('咨询意向缺少单位、联系人、电话、方向、现状或需求')
    error.statusCode = 400
    error.code = 'invalid_consulting_request'
    throw error
  }

  return {
    company,
    contactName,
    contactPhone,
    email: String(payload.email || user.email || '').trim(),
    direction,
    organizationType: String(payload.organizationType || payload.organization_type || '').trim(),
    industry: String(payload.industry || '').trim(),
    city: String(payload.city || '').trim(),
    projectStage: String(payload.projectStage || payload.project_stage || '').trim(),
    urgency: String(payload.urgency || '').trim(),
    budgetRange: String(payload.budgetRange || payload.budget_range || '').trim(),
    expectedStart: String(payload.expectedStart || payload.expected_start || '').trim(),
    currentSituation,
    requirement,
    goals: String(payload.goals || '').trim(),
    preferredContactTime: String(payload.preferredContactTime || payload.preferred_contact_time || '').trim(),
  }
}

function consultingOwner(direction = '') {
  if (direction.includes('数据')) return '数据治理研究部'
  if (direction.includes('城市')) return '智慧城市研究部'
  if (direction.includes('产业')) return '产业数字化研究部'
  return '规划咨询部'
}

function consultingStep(status) {
  if (!status) return null
  if (status === 'pending') return 0
  if (status === 'assigned') return 1
  if (status === 'communicating') return 2
  if (status === 'planning') return 3
  if (status === 'proposal_ready' || status === 'pending_payment') return 4
  if (status === 'in_service') return 5
  if (status === 'completed') return 6
  return null
}

function consultingRequestJson(alias = 'cr') {
  return `json_build_object(
    'id', ${alias}.id,
    'requestNo', ${alias}.request_no,
    'userId', ${alias}.user_id,
    'status', ${alias}.status,
    'currentStep', ${alias}.current_step,
    'company', ${alias}.company,
    'contactName', ${alias}.contact_name,
    'contactPhone', ${alias}.contact_phone,
    'email', ${alias}.email,
    'direction', ${alias}.direction,
    'organizationType', ${alias}.organization_type,
    'industry', ${alias}.industry,
    'city', ${alias}.city,
    'projectStage', ${alias}.project_stage,
    'urgency', ${alias}.urgency,
    'budgetRange', ${alias}.budget_range,
    'expectedStart', ${alias}.expected_start,
    'currentSituation', ${alias}.current_situation,
    'requirement', ${alias}.requirement,
    'goals', ${alias}.goals,
    'preferredContactTime', ${alias}.preferred_contact_time,
    'owner', ${alias}.owner_department,
    'assignedAccountId', ${alias}.assigned_account_id,
    'progressNote', ${alias}.progress_note,
    'solutionSummary', ${alias}.solution_summary,
    'chargeRequired', ${alias}.charge_required,
    'quotedTitle', ${alias}.quoted_title,
    'quotedAmount', ${alias}.quoted_amount,
    'originalQuotedAmount', ${alias}.original_quoted_amount,
    'membershipDiscountAmount', ${alias}.membership_discount_amount,
    'membershipDiscountNote', ${alias}.membership_discount_note,
    'membershipLevelSnapshot', ${alias}.membership_level_snapshot,
    'quotedNote', ${alias}.quoted_note,
    'quotedAt', ${alias}.quoted_at,
    'confirmedAt', ${alias}.confirmed_at,
    'orderId', ${alias}.order_id,
    'paidAt', ${alias}.paid_at,
    'finalSummary', ${alias}.final_summary,
    'finalFileId', ${alias}.final_file_id,
    'finalFileUrl', case when ${alias}.final_file_id is null then '' else '/api/files/' || ${alias}.final_file_id end,
    'internalNote', ${alias}.internal_note,
    'acceptedAt', ${alias}.accepted_at,
    'completedAt', ${alias}.completed_at,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  )`
}

function normalizeDataServicePayload(payload = {}, user) {
  const company = String(payload.company || user.organization || '未填写机构').trim()
  const contactName = String(payload.contactName || payload.contact_name || user.displayName || '平台用户').trim()
  const contactPhone = String(payload.contactPhone || payload.contact_phone || user.phone || '').trim()
  const dataTopic = String(payload.dataTopic || payload.data_topic || payload.topic || '').trim()
  const serviceType = String(payload.serviceType || payload.service_type || '数据包定制').trim()
  const requirement = String(payload.requirement || payload.description || '').trim()
  const expectedResult = String(payload.expectedResult || payload.expected_result || payload.result || '').trim()

  if (!company || !contactName || !contactPhone || !dataTopic || !serviceType || !requirement || !expectedResult) {
    const error = new Error('数据服务申请缺少单位、联系人、电话、数据主题、服务类型、需求描述或期望成果')
    error.statusCode = 400
    error.code = 'invalid_data_service_request'
    throw error
  }

  return {
    company,
    organizationType: String(payload.organizationType || payload.organization_type || '').trim(),
    contactName,
    contactPhone,
    email: String(payload.email || user.email || '').trim(),
    city: String(payload.city || '').trim(),
    dataTopic,
    serviceType,
    regionScope: String(payload.regionScope || payload.region_scope || '').trim(),
    timeRange: String(payload.timeRange || payload.time_range || '').trim(),
    industryScene: String(payload.industryScene || payload.industry_scene || payload.industry || '').trim(),
    expectedFormat: String(payload.expectedFormat || payload.expected_format || '').trim(),
    dataVolume: String(payload.dataVolume || payload.data_volume || '').trim(),
    usagePurpose: String(payload.usagePurpose || payload.usage_purpose || '').trim(),
    requirement,
    expectedResult,
    deadline: String(payload.deadline || '').trim(),
    budgetRange: String(payload.budgetRange || payload.budget_range || '').trim(),
    sensitiveData: Boolean(payload.sensitiveData ?? payload.sensitive_data ?? false),
    preferredContactTime: String(payload.preferredContactTime || payload.preferred_contact_time || '').trim(),
    extraNote: String(payload.extraNote || payload.extra_note || '').trim(),
  }
}

function dataServiceStep(status) {
  if (!status) return null
  if (status === 'pending_contact') return 1
  if (status === 'contacting') return 2
  if (status === 'quoted' || status === 'pending_payment') return 3
  if (status === 'in_service') return 4
  if (status === 'completed') return 5
  return null
}

function dataServiceRequestJson(alias = 'dsr', userAlias = null, staffAlias = null, orderAlias = null, fileAlias = null) {
  return `(jsonb_build_object(
    'id', ${alias}.id,
    'requestNo', ${alias}.request_no,
    'userId', ${alias}.user_id,
    'userPhone', ${userAlias ? `${userAlias}.phone` : 'null'},
    'orderId', ${alias}.order_id,
    'orderNo', ${orderAlias ? `${orderAlias}.order_no` : 'null'},
    'status', ${alias}.status,
    'currentStep', ${alias}.current_step,
    'company', ${alias}.company,
    'organizationType', ${alias}.organization_type,
    'contactName', ${alias}.contact_name,
    'contactPhone', ${alias}.contact_phone,
    'email', ${alias}.email,
    'city', ${alias}.city,
    'dataTopic', ${alias}.data_topic,
    'serviceType', ${alias}.service_type,
    'regionScope', ${alias}.region_scope,
    'timeRange', ${alias}.time_range,
    'industryScene', ${alias}.industry_scene,
    'expectedFormat', ${alias}.expected_format,
    'dataVolume', ${alias}.data_volume,
    'usagePurpose', ${alias}.usage_purpose,
    'requirement', ${alias}.requirement,
    'expectedResult', ${alias}.expected_result,
    'deadline', ${alias}.deadline,
    'budgetRange', ${alias}.budget_range,
    'sensitiveData', ${alias}.sensitive_data,
    'preferredContactTime', ${alias}.preferred_contact_time,
    'extraNote', ${alias}.extra_note,
    'assignedAccountId', ${alias}.assigned_account_id,
    'assigneeName', ${staffAlias ? `${staffAlias}.display_name` : 'null'}
  ) || jsonb_build_object(
    'contactNote', ${alias}.contact_note,
    'progressNote', ${alias}.progress_note,
    'quotedTitle', ${alias}.quoted_title,
    'quotedAmount', ${alias}.quoted_amount,
    'originalQuotedAmount', ${alias}.original_quoted_amount,
    'membershipDiscountAmount', ${alias}.membership_discount_amount,
    'membershipDiscountNote', ${alias}.membership_discount_note,
    'membershipLevelSnapshot', ${alias}.membership_level_snapshot,
    'quotedNote', ${alias}.quoted_note,
    'quotedAt', ${alias}.quoted_at,
    'confirmedAt', ${alias}.confirmed_at,
    'solutionSummary', ${alias}.solution_summary,
    'paidAt', ${alias}.paid_at,
    'deliverableFileId', ${alias}.deliverable_file_id,
    'deliverableFileIds', coalesce(to_jsonb(${alias}.deliverable_file_ids), '[]'::jsonb),
    'deliverableFileUrl', case when ${alias}.deliverable_file_id is null then '' else '/api/files/' || ${alias}.deliverable_file_id end,
    'deliverableFileName', ${fileAlias ? `${fileAlias}.file_name` : 'null'},
    'deliverableFileSize', ${fileAlias ? `${fileAlias}.file_size` : 'null'},
    'deliverableFiles', coalesce((
      select jsonb_agg(jsonb_build_object(
        'id', df.id,
        'fileName', df.file_name,
        'mimeType', df.mime_type,
        'fileSize', df.file_size,
        'url', '/api/files/' || df.id,
        'createdAt', df.created_at
      ) order by df.created_at)
      from uploaded_files df
      where df.id = any(${alias}.deliverable_file_ids)
    ), '[]'::jsonb)
  ) || jsonb_build_object(
    'resultSummary', ${alias}.result_summary,
    'internalNote', ${alias}.internal_note,
    'completedAt', ${alias}.completed_at,
    'createdAt', ${alias}.created_at,
    'updatedAt', ${alias}.updated_at
  ))`
}

function isUuid(value) {
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || ''))
}

async function publicStaffAccount(account, resolvedPermissions = null) {
  const permissions = resolvedPermissions || await getStaffEffectivePermissions(account)
  const role = await getStaffRoleOrFallback(account.role)
  return {
    id: account.id,
    email: account.email,
    phone: account.phone,
    displayName: account.displayName,
    role: account.role,
    roleName: account.roleName || role?.name || account.role,
    status: account.status,
    positionTitle: account.positionTitle || '',
    remark: account.remark || '',
    defaultEntry: account.defaultEntry || role?.defaultEntry || staffRoleFallback(account.role)?.defaultEntry || '/staff',
    permissions,
    lastLoginAt: account.lastLoginAt || null,
    createdAt: account.createdAt,
    updatedAt: account.updatedAt,
  }
}

async function getStaffAccountByToken(token) {
  return findActiveStaffAccountByToken(token)
}

function defaultPurchaseFlow(type) {
  if (type === 'membership') return 'member_checkout'
  if (type === 'event') return 'free_claim'
  if (type === 'expert') return 'booking'
  if (type === 'consulting') return 'application'
  return 'cart'
}

function defaultFulfillmentFlow(type) {
  if (type === 'agent') return 'task'
  if (type === 'expert') return 'booking'
  if (type === 'data') return 'download'
  if (type === 'event') return 'checkin'
  if (type === 'consulting') return 'consulting'
  return 'entitlement'
}

async function auditAdmin(actor, action, resource, resourceId, detail) {
  await executeSql(`
    insert into admin_audit_logs (actor, action, resource, resource_id, detail)
    values (
      ${sqlLiteral(actor)},
      ${sqlLiteral(action)},
      ${sqlLiteral(resource)},
      ${sqlLiteral(resourceId)},
      ${sqlLiteral(JSON.stringify(detail || {}))}::jsonb
    );
  `)
}

async function auditStaff({
  actorAccount = null,
  actorEmail = '',
  action,
  targetType = 'staff_account',
  targetId = '',
  summary = '',
  metadata = {},
} = {}) {
  if (!action) return
  const actorIdSql = actorAccount?.id && isUuid(actorAccount.id) ? `${sqlLiteral(actorAccount.id)}::uuid` : 'null'
  try {
    await executeSql(`
      insert into staff_audit_logs (
        actor_account_id, actor_email, action, target_type, target_id, summary, metadata
      )
      values (
        ${actorIdSql},
        ${sqlLiteral(actorEmail || actorAccount?.email || '')},
        ${sqlLiteral(action)},
        ${sqlLiteral(targetType)},
        ${sqlLiteral(String(targetId || ''))},
        ${sqlLiteral(summary)},
        ${sqlLiteral(JSON.stringify(metadata || {}))}::jsonb
      );
    `)
  } catch (error) {
    if (String(error?.message || '').includes('staff_audit_logs')) return
    throw error
  }
}

async function getAdminProductById(productId) {
  return queryJson(`
    select ${productJson('p')}
    from products p
    where p.id = ${sqlLiteral(productId)}
    limit 1;
  `)
}

async function getAdminNewsArticleById(articleId) {
  return queryJson(`
    select ${newsArticleJson('n')}
    from news_articles n
    where n.id = ${sqlLiteral(articleId)}::uuid
    limit 1;
  `)
}

async function getAdminCouponById(couponId) {
  return queryJson(`
    select ${couponJson('c')}
    from coupons c
    where c.id = ${sqlLiteral(couponId)}::uuid
    limit 1;
  `)
}

async function getUserById(userId) {
  return queryJson(`
    select ${userJson('u')}
    from users u
    where u.id = ${sqlLiteral(userId)}::uuid
    limit 1;
  `)
}

async function resolveAdminCouponPreviewUser(payload = {}) {
  const userId = payload.userId || payload.user_id || ''
  const passportNo = normalizePassportNo(payload.passportNo || payload.passport_no || '')
  if (isUuid(userId)) {
    const user = await getUserById(userId)
    if (user) return user
  }
  if (passportNo) {
    const user = await getUserByPassportNo(passportNo)
    if (user) return user
  }
  return {
    id: null,
    passportNo,
    membershipLevel: payload.membershipLevel || payload.membership_level || 'registered',
    createdAt: new Date().toISOString(),
  }
}

async function getUserOrder(userId, orderKey) {
  return findUserOrder(userId, orderKey)
}

async function getPaymentByNo(paymentNo) {
  return findPaymentByNo(paymentNo)
}

async function resolveCouponPreview(code, items, user, couponId = '', couponOverride = null) {
  if (couponOverride) {
    return buildCouponPreview(couponOverride, items, user)
  }
  if (!code && !couponId) return null
  const couponWhere = couponId
    ? `c.id = ${sqlLiteral(couponId)}::uuid`
    : `c.code = ${sqlLiteral(String(code || '').trim().toUpperCase())}`
  const coupon = await queryJson(`
    select ${couponJson('c')}
    from coupons c
    where ${couponWhere}
      and c.status = 'active'
      and c.starts_at <= now()
      and (c.ends_at is null or c.ends_at > now())
      and c.used_quantity < c.total_quantity
    limit 1;
  `)

  if (!coupon) {
    const error = new Error('优惠码无效、已过期或已用完')
    error.statusCode = 400
    error.code = 'invalid_coupon'
    throw error
  }

  return buildCouponPreview(coupon, items, user)
}

async function buildCouponPreview(coupon, items, user) {
  assertCouponAvailability(coupon)
  await assertCouponUserScope(coupon, user)
  await assertCouponUsageLimits(coupon, user)

  const eligibleItems = couponEligibleItems(coupon, items)
  const eligibleSubtotal = eligibleItems.reduce((sum, item) => sum + item.unitPriceAmount * item.quantity, 0)
  if (eligibleSubtotal <= 0) {
    const error = new Error('当前商品不在优惠码适用范围内')
    error.statusCode = 400
    error.code = 'coupon_product_scope_not_met'
    throw error
  }

  const stackingPolicy = normalizeCouponStackingPolicy(coupon.stackingPolicy)
  const eligibleMemberDiscount = stackingPolicy.allowMemberDiscount
    ? eligibleItems.reduce((sum, item) => {
      if (item.memberDiscountEligible === false) return sum
      return sum + Math.round(item.unitPriceAmount * item.quantity * memberDiscountRate(user.membershipLevel))
    }, 0)
    : 0
  const discountBaseAmount = Math.max(eligibleSubtotal - eligibleMemberDiscount, 0)

  if (discountBaseAmount < coupon.minOrderAmount) {
    const error = new Error('订单金额未达到优惠码使用门槛')
    error.statusCode = 400
    error.code = 'coupon_min_order_not_met'
    throw error
  }

  const rawDiscount = coupon.discountType === 'percent'
    ? Math.round(discountBaseAmount * coupon.discountValue / 100)
    : coupon.discountValue
  const cappedDiscount = coupon.maxDiscountAmount !== null && coupon.maxDiscountAmount !== undefined
    ? Math.min(rawDiscount, coupon.maxDiscountAmount)
    : rawDiscount
  const budgetRemaining = coupon.budgetAmount !== null && coupon.budgetAmount !== undefined
    ? Math.max(coupon.budgetAmount - (coupon.usedDiscountAmount || 0), 0)
    : null
  if (budgetRemaining !== null && budgetRemaining <= 0) {
    const error = new Error('优惠码预算已用完')
    error.statusCode = 400
    error.code = 'coupon_budget_exhausted'
    throw error
  }
  const discountAmount = Math.min(cappedDiscount, Math.max(discountBaseAmount, 0), budgetRemaining ?? Number.MAX_SAFE_INTEGER)

  return {
    id: coupon.id,
    code: coupon.code,
    name: coupon.name,
    description: coupon.description,
    discountType: coupon.discountType,
    discountValue: coupon.discountValue,
    maxDiscountAmount: coupon.maxDiscountAmount,
    minOrderAmount: coupon.minOrderAmount,
    eligibleSubtotal,
    discountBaseAmount,
    discountAmount,
    remainingQuantity: coupon.remainingQuantity,
    productScope: coupon.productScope,
    userScope: coupon.userScope,
    usageLimits: coupon.usageLimits,
    stackingPolicy,
    budgetAmount: coupon.budgetAmount,
    usedDiscountAmount: coupon.usedDiscountAmount,
    eligibleItems: eligibleItems.map((item) => ({
      productId: item.productId,
      productType: item.productType,
      titleSnapshot: item.titleSnapshot,
      amount: item.unitPriceAmount * item.quantity,
    })),
    endsAt: coupon.endsAt,
  }
}

function assertCouponAvailability(coupon) {
  const now = Date.now()
  if (coupon.status !== 'active') {
    const error = new Error('优惠码未启用')
    error.statusCode = 400
    error.code = 'coupon_not_active'
    throw error
  }
  const startsAt = new Date(coupon.startsAt).getTime()
  const endsAt = coupon.endsAt ? new Date(coupon.endsAt).getTime() : null
  if (Number.isFinite(startsAt) && startsAt > now) {
    const error = new Error('优惠码尚未开始')
    error.statusCode = 400
    error.code = 'coupon_not_started'
    throw error
  }
  if (endsAt && endsAt <= now) {
    const error = new Error('优惠码已过期')
    error.statusCode = 400
    error.code = 'coupon_expired'
    throw error
  }
  if ((coupon.usedQuantity || 0) >= coupon.totalQuantity) {
    const error = new Error('优惠码已用完')
    error.statusCode = 400
    error.code = 'coupon_quantity_exhausted'
    throw error
  }
}

function finalizeCouponDiscount(couponPreview) {
  return couponPreview?.discountAmount || 0
}

function couponEligibleItems(coupon, items) {
  const scope = normalizeCouponProductScope(coupon.productScope)
  const excludeIds = new Set(scope.excludeProductIds)
  return items.filter((item) => {
    if (item.dataServiceRequestId) return false
    if (excludeIds.has(item.productId)) return false
    if (scope.mode !== 'include') return true
    const typeMatch = scope.productTypes.length > 0 && scope.productTypes.includes(item.productType)
    const productMatch = scope.productIds.length > 0 && scope.productIds.includes(item.productId)
    return typeMatch || productMatch
  })
}

async function assertCouponUserScope(coupon, user) {
  const scope = normalizeCouponUserScope(coupon.userScope)
  const membershipLevel = user?.membershipLevel || 'registered'
  if (scope.membershipLevels.length > 0 && !scope.membershipLevels.includes(membershipLevel)) {
    const error = new Error('当前会员等级不可使用该优惠码')
    error.statusCode = 400
    error.code = 'coupon_membership_scope_not_met'
    throw error
  }
  if (scope.passportNos.length > 0 && !scope.passportNos.includes(normalizePassportNo(user?.passportNo || ''))) {
    const error = new Error('当前通行证不可使用该优惠码')
    error.statusCode = 400
    error.code = 'coupon_passport_scope_not_met'
    throw error
  }
  if (scope.userIds.length > 0 && (!user?.id || !scope.userIds.includes(user.id))) {
    const error = new Error('当前用户不可使用该优惠码')
    error.statusCode = 400
    error.code = 'coupon_user_scope_not_met'
    throw error
  }
  if (scope.firstOrderOnly && user?.id) {
    const orderCount = await userOrderCount(user.id)
    if (orderCount > 0) {
      const error = new Error('该优惠码仅限首单用户使用')
      error.statusCode = 400
      error.code = 'coupon_first_order_only'
      throw error
    }
  }
  if (scope.newUserOnly && user?.createdAt) {
    const createdAt = new Date(user.createdAt).getTime()
    if (!Number.isFinite(createdAt) || Date.now() - createdAt > 1000 * 60 * 60 * 24 * 30) {
      const error = new Error('该优惠码仅限注册 30 天内的新用户使用')
      error.statusCode = 400
      error.code = 'coupon_new_user_only'
      throw error
    }
  }
}

async function assertCouponUsageLimits(coupon, user) {
  const limits = normalizeCouponUsageLimits(coupon.usageLimits)
  if (!user?.id || !isUuid(coupon.id)) return
  if (limits.perUser > 0) {
    const count = await couponRedemptionCount(coupon.id, user.id)
    if (count >= limits.perUser) {
      const error = new Error('该优惠码已达到每人使用次数上限')
      error.statusCode = 400
      error.code = 'coupon_user_limit_reached'
      throw error
    }
  }
  if (limits.perDay > 0) {
    const count = await couponRedemptionCount(coupon.id, user.id, true)
    if (count >= limits.perDay) {
      const error = new Error('该优惠码已达到每日使用次数上限')
      error.statusCode = 400
      error.code = 'coupon_daily_limit_reached'
      throw error
    }
  }
}

async function userOrderCount(userId) {
  const result = await queryJson(`
    select json_build_object('count', count(*)::int)
    from orders
    where user_id = ${sqlLiteral(userId)}::uuid;
  `)
  return result?.count || 0
}

async function couponRedemptionCount(couponId, userId, todayOnly = false) {
  const result = await queryJson(`
    select json_build_object('count', count(*)::int)
    from coupon_redemptions
    where coupon_id = ${sqlLiteral(couponId)}::uuid
      and user_id = ${sqlLiteral(userId)}::uuid
      and (${todayOnly ? 'created_at >= date_trunc(\'day\', now())' : 'true'});
  `)
  return result?.count || 0
}

function buildExpertSettlementPreview(items, subtotalAmount, discountAmount) {
  const expertItems = items.filter((item) => item.productType === 'expert')
  if (!expertItems.length) return null

  const records = expertItems.map((item) => {
    const itemAmount = item.unitPriceAmount * item.quantity
    const discountShare = subtotalAmount > 0 ? Math.round(discountAmount * itemAmount / subtotalAmount) : 0
    const grossAmount = Math.max(itemAmount - discountShare, 0)
    const expertShareAmount = Math.round(grossAmount * 0.8)
    return {
      productId: item.productId,
      titleSnapshot: item.titleSnapshot,
      grossAmount,
      expertShareAmount,
      platformShareAmount: grossAmount - expertShareAmount,
      policyName: '专家咨询默认 8/2 分成',
    }
  })

  return {
    grossAmount: records.reduce((sum, item) => sum + item.grossAmount, 0),
    expertShareAmount: records.reduce((sum, item) => sum + item.expertShareAmount, 0),
    platformShareAmount: records.reduce((sum, item) => sum + item.platformShareAmount, 0),
    policyName: '专家咨询默认 8/2 分成',
    items: records,
  }
}

async function getOrderById(orderId) {
  return findOrderById(orderId)
}
