文件预览

validators.js

查看 噗滋慈善 - 申报助手 / pozzzi-charity application-assistant 技能包中的文件内容。

文件内容

src/validators.js

'use strict';

// -----------------------------------------------------------------------
// validators.js — 申报助手输入校验器
//
// 依赖:仅 Node.js 内置模块
// 合规依据:噗滋慈善申报助手 Spec v1.0 + PIPL 第23/28条 + 慈善法第75/77条
// 架构复用:与 report-assistant/src/validators.js 框架保持一致
// -----------------------------------------------------------------------

/**
 * 合并两个校验结果对象(将 src 的 errors/warnings 追加到 dest)
 * @param {{ errors: string[], warnings: string[] }} dest
 * @param {{ errors: string[], warnings: string[] }} src
 */
function mergeResult(dest, src) {
  dest.errors.push(...src.errors);
  dest.warnings.push(...src.warnings);
}

// -----------------------------------------------------------------------
// 1. 通用输入校验
// -----------------------------------------------------------------------

/**
 * 校验所有申报类型共用的必填字段。
 *
 * PII 安全注释:contact_name / contact_phone 在此仅做非空校验,
 * 不进入 Prompt,由 validatePIISafety 负责标记和排除。
 *
 * @param {object} input
 * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
 */
function validateCommonInput(input) {
  const errors = [];
  const warnings = [];

  if (!input || typeof input !== 'object') {
    return { valid: false, errors: ['input 必须是对象'], warnings: [] };
  }

  // org_name — 非空字符串
  if (!input.org_name || typeof input.org_name !== 'string' || !input.org_name.trim()) {
    errors.push('org_name 为必填字段,不能为空');
  }

  // org_type — enum
  const validOrgTypes = ['社会团体', '民办非企业单位', '基金会'];
  if (!input.org_type) {
    errors.push('org_type 为必填字段');
  } else if (!validOrgTypes.includes(input.org_type)) {
    errors.push(
      `org_type 值无效:"${input.org_type}",有效值为:${validOrgTypes.join('/')}`,
    );
  }

  // application_type — enum
  const validApplicationTypes = ['generic', 'tencent99', 'govt_purchase'];
  if (!input.application_type) {
    errors.push('application_type 为必填字段');
  } else if (!validApplicationTypes.includes(input.application_type)) {
    errors.push(
      `application_type 值无效:"${input.application_type}",有效值为:${validApplicationTypes.join('/')}`,
    );
  }

  // project_name — 非空字符串
  if (!input.project_name || typeof input.project_name !== 'string' || !input.project_name.trim()) {
    errors.push('project_name 为必填字段,不能为空');
  }

  // project_domain — enum
  const validProjectDomains = [
    'education', 'health', 'elderly', 'disability', 'poverty',
    'environment', 'women_children', 'community', 'arts_culture', 'other',
  ];
  if (!input.project_domain) {
    errors.push('project_domain 为必填字段');
  } else if (!validProjectDomains.includes(input.project_domain)) {
    errors.push(
      `project_domain 值无效:"${input.project_domain}",有效值为:${validProjectDomains.join('/')}`,
    );
  }

  // contact_name — 必填(PII,仅存本地日志,不进模型)
  if (
    !input.contact_name ||
    typeof input.contact_name !== 'string' ||
    !input.contact_name.trim()
  ) {
    errors.push('contact_name 为必填字段,不能为空');
  }

  return { valid: errors.length === 0, errors, warnings };
}

// -----------------------------------------------------------------------
// 2. 项目设计输入校验
// -----------------------------------------------------------------------

/**
 * 校验项目设计相关字段(所有申报类型共用)。
 * 应在 validateCommonInput 通过后调用。
 *
 * @param {object} input
 * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
 */
function validateProjectDesignInput(input) {
  const errors = [];
  const warnings = [];

  if (!input || typeof input !== 'object') {
    return { valid: false, errors: ['input 必须是对象'], warnings: [] };
  }

  // project_goal — 50-200 字
  if (!input.project_goal || typeof input.project_goal !== 'string' || !input.project_goal.trim()) {
    errors.push('project_goal 为必填字段,不能为空');
  } else {
    const goalLen = input.project_goal.trim().length;
    if (goalLen < 50) {
      errors.push(`project_goal 不能少于 50 个字符,当前:${goalLen} 个字符`);
    } else if (goalLen > 200) {
      errors.push(`project_goal 不能超过 200 个字符,当前:${goalLen} 个字符`);
    }
  }

  // project_objectives — 非空数组,每项有 description + indicator
  if (!Array.isArray(input.project_objectives) || input.project_objectives.length === 0) {
    errors.push('project_objectives 为必填字段,不能为空数组');
  } else {
    input.project_objectives.forEach((obj, idx) => {
      if (!obj.description || typeof obj.description !== 'string' || !obj.description.trim()) {
        errors.push(`project_objectives[${idx}].description 为必填字段,不能为空`);
      }
      if (!obj.indicator || typeof obj.indicator !== 'string' || !obj.indicator.trim()) {
        errors.push(`project_objectives[${idx}].indicator 为必填字段,不能为空`);
      }
    });
  }

  // activities_design — 非空数组
  if (!Array.isArray(input.activities_design) || input.activities_design.length === 0) {
    errors.push('activities_design 为必填字段,不能为空数组');
  } else {
    input.activities_design.forEach((act, idx) => {
      if (!act.name || typeof act.name !== 'string' || !act.name.trim()) {
        errors.push(`activities_design[${idx}].name 为必填字段,不能为空`);
      }
      if (!act.description || typeof act.description !== 'string' || !act.description.trim()) {
        errors.push(`activities_design[${idx}].description 为必填字段,不能为空`);
      }
      if (!act.frequency || typeof act.frequency !== 'string' || !act.frequency.trim()) {
        errors.push(`activities_design[${idx}].frequency 为必填字段,不能为空`);
      }
    });
  }

  // timeline — 非空数组,end_month > start_month
  if (!Array.isArray(input.timeline) || input.timeline.length === 0) {
    errors.push('timeline 为必填字段,不能为空数组');
  } else {
    input.timeline.forEach((phase, idx) => {
      if (!phase.phase || typeof phase.phase !== 'string' || !phase.phase.trim()) {
        errors.push(`timeline[${idx}].phase 为必填字段,不能为空`);
      }
      const startMonth = Number(phase.start_month);
      const endMonth = Number(phase.end_month);
      if (!Number.isInteger(startMonth) || startMonth < 1) {
        errors.push(`timeline[${idx}].start_month 必须是正整数`);
      }
      if (!Number.isInteger(endMonth) || endMonth < 1) {
        errors.push(`timeline[${idx}].end_month 必须是正整数`);
      }
      if (Number.isInteger(startMonth) && Number.isInteger(endMonth) && endMonth <= startMonth) {
        errors.push(
          `timeline[${idx}](${phase.phase || '未命名阶段'})end_month(${endMonth})必须大于 start_month(${startMonth})`,
        );
      }
      if (!phase.key_tasks || typeof phase.key_tasks !== 'string' || !phase.key_tasks.trim()) {
        errors.push(`timeline[${idx}].key_tasks 为必填字段,不能为空`);
      }
    });

    // 最后一个阶段的 end_month 不应超过 project_duration_months
    if (
      typeof input.project_duration_months === 'number' &&
      input.project_duration_months > 0
    ) {
      const lastPhase = input.timeline[input.timeline.length - 1];
      const lastEndMonth = Number(lastPhase.end_month);
      if (Number.isInteger(lastEndMonth) && lastEndMonth > input.project_duration_months) {
        errors.push(
          `timeline 最后阶段的 end_month(${lastEndMonth})超过了 project_duration_months(${input.project_duration_months}),请检查时间表`,
        );
      }
    }
  }

  // project_duration_months — 正整数
  if (
    input.project_duration_months === undefined ||
    input.project_duration_months === null
  ) {
    errors.push('project_duration_months 为必填字段');
  } else {
    const dur = Number(input.project_duration_months);
    if (!Number.isInteger(dur) || dur <= 0) {
      errors.push('project_duration_months 必须是正整数');
    }
  }

  // project_start_date — 格式 YYYY-MM
  if (!input.project_start_date || typeof input.project_start_date !== 'string') {
    errors.push('project_start_date 为必填字段,格式应为 YYYY-MM');
  } else {
    const ymPattern = /^\d{4}-(0[1-9]|1[0-2])$/;
    if (!ymPattern.test(input.project_start_date.trim())) {
      errors.push(
        `project_start_date 格式不正确:"${input.project_start_date}",期望格式:YYYY-MM(如 2026-07)`,
      );
    }
  }

  return { valid: errors.length === 0, errors, warnings };
}

// -----------------------------------------------------------------------
// 3. 预算输入校验
// -----------------------------------------------------------------------

/**
 * 校验预算相关字段(所有申报类型共用)。
 * 应在 validateCommonInput 通过后调用。
 *
 * 合规依据(合规红线第8条):
 * - total_budget 和 budget_breakdown[].amount 必须由用户填写
 * - Skill 不生成、推算或补充任何预算金额
 * - sum(breakdown.amount) 与 total_budget 差异 > 100 元 → error(阻断)
 *
 * @param {object} input
 * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
 */
function validateBudgetInput(input) {
  const errors = [];
  const warnings = [];

  if (!input || typeof input !== 'object') {
    return { valid: false, errors: ['input 必须是对象'], warnings: [] };
  }

  // total_budget — 正数
  const hasTotalBudget =
    typeof input.total_budget === 'number' &&
    isFinite(input.total_budget) &&
    input.total_budget > 0;

  if (input.total_budget === undefined || input.total_budget === null) {
    errors.push('total_budget 为必填字段');
  } else if (!hasTotalBudget) {
    errors.push('total_budget 必须是大于 0 的数值');
  }

  // budget_breakdown — 非空数组,每项有 category/amount/description
  if (!Array.isArray(input.budget_breakdown) || input.budget_breakdown.length === 0) {
    errors.push('budget_breakdown 为必填字段,不能为空数组');
  } else {
    input.budget_breakdown.forEach((item, idx) => {
      if (!item.category || typeof item.category !== 'string' || !item.category.trim()) {
        errors.push(`budget_breakdown[${idx}].category 为必填字段,不能为空`);
      }
      if (typeof item.amount !== 'number' || !isFinite(item.amount) || item.amount <= 0) {
        errors.push(`budget_breakdown[${idx}].amount 必须是大于 0 的数值`);
      }
      if (!item.description || typeof item.description !== 'string' || !item.description.trim()) {
        errors.push(`budget_breakdown[${idx}].description 为必填字段,不能为空`);
      }
    });

    // 预算一致性校验:sum(breakdown.amount) 与 total_budget 差异 ≤ 100 元
    if (hasTotalBudget) {
      const sumBreakdown = input.budget_breakdown.reduce((acc, item) => {
        return acc + (typeof item.amount === 'number' && isFinite(item.amount) ? item.amount : 0);
      }, 0);
      const diff = Math.abs(input.total_budget - sumBreakdown);
      if (diff > 100) {
        errors.push(
          `budget_breakdown 各项金额之和(${sumBreakdown} 元)与 total_budget(${input.total_budget} 元)差异为 ${diff.toFixed(2)} 元,超过允许误差 100 元,请核实后继续`,
        );
      }

      // percentage 自动计算校验(用户填了才校验)
      input.budget_breakdown.forEach((item, idx) => {
        if (item.percentage !== undefined && item.percentage !== null) {
          const expectedPct = (item.amount / input.total_budget) * 100;
          const pctDiff = Math.abs(item.percentage - expectedPct);
          if (pctDiff > 0.5) {
            warnings.push(
              `budget_breakdown[${idx}](${item.category || '未命名'})percentage(${item.percentage}%)与计算值(${expectedPct.toFixed(2)}%)差异超过 0.5%,建议核实`,
            );
          }
        }
      });
    }
  }

  // has_matching_fund — boolean 类型
  if (input.has_matching_fund === undefined || input.has_matching_fund === null) {
    errors.push('has_matching_fund 为必填字段');
  } else if (typeof input.has_matching_fund !== 'boolean') {
    errors.push('has_matching_fund 必须是布尔值(true/false)');
  } else if (input.has_matching_fund === true) {
    // has_matching_fund=true 时,matching_fund_amount + matching_fund_source 必填
    if (
      input.matching_fund_amount === undefined ||
      input.matching_fund_amount === null
    ) {
      errors.push('has_matching_fund 为 true 时,matching_fund_amount 为必填字段');
    } else if (
      typeof input.matching_fund_amount !== 'number' ||
      !isFinite(input.matching_fund_amount) ||
      input.matching_fund_amount <= 0
    ) {
      errors.push('matching_fund_amount 必须是大于 0 的数值');
    }

    if (
      !input.matching_fund_source ||
      typeof input.matching_fund_source !== 'string' ||
      !input.matching_fund_source.trim()
    ) {
      errors.push('has_matching_fund 为 true 时,matching_fund_source 为必填字段,不能为空');
    }
  }

  return { valid: errors.length === 0, errors, warnings };
}

// -----------------------------------------------------------------------
// 4. 腾讯公益99公益日专项校验
// -----------------------------------------------------------------------

/**
 * 腾讯公益99公益日专用字段校验。
 * 应在 application_type === 'tencent99' 时调用。
 *
 * @param {object} input
 * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
 */
function validateTencent99Input(input) {
  const errors = [];
  const warnings = [];

  if (!input || typeof input !== 'object') {
    return { valid: false, errors: ['input 必须是对象'], warnings: [] };
  }

  // tencent_fundraising_goal — 正数
  if (
    input.tencent_fundraising_goal === undefined ||
    input.tencent_fundraising_goal === null
  ) {
    errors.push('tencent_fundraising_goal 为必填字段');
  } else if (
    typeof input.tencent_fundraising_goal !== 'number' ||
    !isFinite(input.tencent_fundraising_goal) ||
    input.tencent_fundraising_goal <= 0
  ) {
    errors.push('tencent_fundraising_goal 必须是大于 0 的数值');
  } else {
    // 筹款目标不应超过 total_budget 的 2 倍(警告,不阻断)
    if (
      typeof input.total_budget === 'number' &&
      input.total_budget > 0 &&
      input.tencent_fundraising_goal > input.total_budget * 2
    ) {
      warnings.push(
        `tencent_fundraising_goal(${input.tencent_fundraising_goal} 元)超过 total_budget(${input.total_budget} 元)的 2 倍,请确认筹款目标是否合理`,
      );
    }
  }

  // tencent_project_category — 非空字符串
  if (
    !input.tencent_project_category ||
    typeof input.tencent_project_category !== 'string' ||
    !input.tencent_project_category.trim()
  ) {
    errors.push('tencent_project_category 为必填字段,不能为空');
  }

  // donation_use_description — 非空,100-200 字
  if (
    !input.donation_use_description ||
    typeof input.donation_use_description !== 'string' ||
    !input.donation_use_description.trim()
  ) {
    errors.push('donation_use_description 为必填字段,不能为空');
  } else {
    const descLen = input.donation_use_description.trim().length;
    if (descLen < 100) {
      errors.push(`donation_use_description 不能少于 100 个字符,当前:${descLen} 个字符`);
    } else if (descLen > 200) {
      errors.push(`donation_use_description 不能超过 200 个字符,当前:${descLen} 个字符`);
    }
  }

  // has_previous_99_project — boolean
  if (
    input.has_previous_99_project === undefined ||
    input.has_previous_99_project === null
  ) {
    errors.push('has_previous_99_project 为必填字段');
  } else if (typeof input.has_previous_99_project !== 'boolean') {
    errors.push('has_previous_99_project 必须是布尔值(true/false)');
  } else if (input.has_previous_99_project === true) {
    // has_previous_99_project=true 时 previous_99_outcome 必填
    if (
      !input.previous_99_outcome ||
      typeof input.previous_99_outcome !== 'string' ||
      !input.previous_99_outcome.trim()
    ) {
      errors.push('has_previous_99_project 为 true 时,previous_99_outcome 为必填字段,不能为空');
    }
  }

  return { valid: errors.length === 0, errors, warnings };
}

// -----------------------------------------------------------------------
// 5. 政府购买服务专项校验
// -----------------------------------------------------------------------

/**
 * 政府购买服务专用字段校验。
 * 应在 application_type === 'govt_purchase' 时调用。
 *
 * @param {object} input
 * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
 */
function validateGovtPurchaseInput(input) {
  const errors = [];
  const warnings = [];

  if (!input || typeof input !== 'object') {
    return { valid: false, errors: ['input 必须是对象'], warnings: [] };
  }

  // govt_dept_name — 非空字符串
  if (
    !input.govt_dept_name ||
    typeof input.govt_dept_name !== 'string' ||
    !input.govt_dept_name.trim()
  ) {
    errors.push('govt_dept_name 为必填字段,不能为空');
  }

  // tender_project_name — 非空字符串
  if (
    !input.tender_project_name ||
    typeof input.tender_project_name !== 'string' ||
    !input.tender_project_name.trim()
  ) {
    errors.push('tender_project_name 为必填字段,不能为空');
  }

  // service_area — 非空字符串
  if (
    !input.service_area ||
    typeof input.service_area !== 'string' ||
    !input.service_area.trim()
  ) {
    errors.push('service_area 为必填字段,不能为空');
  }

  // performance_indicators — 非空数组,每项有 indicator/target/measurement
  if (!Array.isArray(input.performance_indicators) || input.performance_indicators.length === 0) {
    errors.push('performance_indicators 为必填字段,不能为空数组');
  } else {
    input.performance_indicators.forEach((item, idx) => {
      if (!item.indicator || typeof item.indicator !== 'string' || !item.indicator.trim()) {
        errors.push(`performance_indicators[${idx}].indicator 为必填字段,不能为空`);
      }
      if (!item.target || typeof item.target !== 'string' || !item.target.trim()) {
        errors.push(`performance_indicators[${idx}].target 为必填字段,不能为空`);
      }
      if (!item.measurement || typeof item.measurement !== 'string' || !item.measurement.trim()) {
        errors.push(`performance_indicators[${idx}].measurement 为必填字段,不能为空`);
      }
    });
  }

  // price_quote — 正数
  if (input.price_quote === undefined || input.price_quote === null) {
    errors.push('price_quote 为必填字段');
  } else if (
    typeof input.price_quote !== 'number' ||
    !isFinite(input.price_quote) ||
    input.price_quote <= 0
  ) {
    errors.push('price_quote 必须是大于 0 的数值');
  }

  // price_breakdown — 非空(字符串或非空数组均可)
  if (
    input.price_breakdown === undefined ||
    input.price_breakdown === null
  ) {
    errors.push('price_breakdown 为必填字段,不能为空');
  } else if (typeof input.price_breakdown === 'string') {
    if (!input.price_breakdown.trim()) {
      errors.push('price_breakdown 为必填字段,不能为空字符串');
    }
  } else if (Array.isArray(input.price_breakdown)) {
    if (input.price_breakdown.length === 0) {
      errors.push('price_breakdown 为必填字段,不能为空数组');
    }
  } else {
    errors.push('price_breakdown 格式不正确,应为字符串或数组');
  }

  return { valid: errors.length === 0, errors, warnings };
}

// -----------------------------------------------------------------------
// 6. PII 安全校验
// -----------------------------------------------------------------------

/**
 * PII 安全校验(申报助手版)。
 *
 * 合规依据:PIPL 第23/28条、未成年人保护法第72条、申报助手 Spec v1.0
 * - 标记 contact_name / contact_phone 为 piiFields,供 prompt-builder 排除
 * - 检测 team_composition[].background 中疑似中文真实姓名(2-4汉字+上下文动词)
 * - 检测 problem_statement 中统计数据模式 → warning 提示填写 needs_data_source
 *
 * @param {object} input
 * @returns {{ piiFields: string[], warnings: string[] }}
 */
function validatePIISafety(input) {
  const piiFields = ['contact_name', 'contact_phone'];
  const warnings = [];

  if (!input || typeof input !== 'object') {
    return { piiFields, warnings };
  }

  // 检测 team_composition[].background 中疑似中文真实姓名
  if (Array.isArray(input.team_composition) && input.team_composition.length > 0) {
    const allBackgrounds = input.team_composition
      .map((m, idx) => ({ idx, text: m.background || '' }))
      .filter(item => typeof item.text === 'string' && item.text.trim());

    // 姓名检测:动词上下文 + 分隔符后2-4汉字(如"项目主任:王小明,…")
    const nameContextPattern = /[\u4e00-\u9fa5]{2,4}(?=是|叫|说|告诉|表示|介绍|反映|名叫|叫做)/g;
    const nameAfterDelimiter = /[::、,]\s*([\u4e00-\u9fa5]{2,4})(?=[,,。\s]|$)/g;

    allBackgrounds.forEach(({ idx, text }) => {
      const matches1 = text.match(nameContextPattern) || [];
      const matches2 = [];
      let dm;
      while ((dm = nameAfterDelimiter.exec(text)) !== null) {
        matches2.push(dm[1]);
      }
      nameAfterDelimiter.lastIndex = 0;
      const matches = [...matches1, ...matches2];
      if (matches && matches.length > 0) {
        const uniqueMatches = [...new Set(matches)];
        warnings.push(
          `team_composition[${idx}].background 中检测到疑似真实姓名:${uniqueMatches.join('、')}。请使用职位/专业背景描述代替真实姓名(如"社会工作专业硕士"),以保护团队成员隐私。`,
        );
      }
    });
  }

  // 检测 problem_statement 中的统计数据模式 → warning 提示填写 needs_data_source
  if (
    input.problem_statement &&
    typeof input.problem_statement === 'string' &&
    input.problem_statement.trim()
  ) {
    // 检测模式:XX万人 / XX% / XX人 / XX亿 等统计数字
    const statsPattern = /\d+(\.\d+)?\s*(万|亿|千)?\s*(人次?|%|个|所|家|名|户|例)/;
    const hasStats = statsPattern.test(input.problem_statement);

    if (hasStats && (!input.needs_data_source || !input.needs_data_source.trim())) {
      warnings.push(
        '您的需求描述(problem_statement)中包含统计数字,建议在 needs_data_source 字段中注明数据来源。AI 不会在申报书中补充或修改您未提供的统计数据。',
      );
    }
  }

  return { piiFields, warnings };
}

// -----------------------------------------------------------------------
// 7. 总入口
// -----------------------------------------------------------------------

/**
 * 总校验入口。按顺序执行:
 * 1. 通用必填字段校验(validateCommonInput)
 * 2. 项目设计字段校验(validateProjectDesignInput)
 * 3. 按 application_type 分发专项校验
 * 4. 预算校验(validateBudgetInput)
 * 5. PII 安全校验(validatePIISafety,仅追加 warnings,不影响 valid)
 *
 * @param {object} input
 * @returns {{ valid: boolean, errors: string[], warnings: string[], piiFields: string[] }}
 */
function validate(input) {
  const result = { valid: true, errors: [], warnings: [], piiFields: [] };

  // Step 1: 通用校验
  const commonResult = validateCommonInput(input);
  mergeResult(result, commonResult);

  // Step 2: 项目设计校验
  const designResult = validateProjectDesignInput(input);
  mergeResult(result, designResult);

  // Step 3: 按 application_type 分发专项校验
  if (input && input.application_type) {
    switch (input.application_type) {
      case 'tencent99': {
        const r = validateTencent99Input(input);
        mergeResult(result, r);
        break;
      }
      case 'govt_purchase': {
        const r = validateGovtPurchaseInput(input);
        mergeResult(result, r);
        break;
      }
      case 'generic':
        // generic 无额外专项字段校验
        break;
      default:
        // application_type 无效已在 validateCommonInput 中报告
        break;
    }
  }

  // Step 4: 预算校验
  const budgetResult = validateBudgetInput(input);
  mergeResult(result, budgetResult);

  // Step 5: PII 安全校验(仅追加 warnings,piiFields,不影响 valid)
  const piiResult = validatePIISafety(input);
  result.warnings.push(...piiResult.warnings);
  result.piiFields = piiResult.piiFields;

  // 汇总:有 error 则 valid = false
  result.valid = result.errors.length === 0;

  return result;
}

// -----------------------------------------------------------------------
// 导出
// -----------------------------------------------------------------------

module.exports = {
  validate,
  validateCommonInput,
  validateProjectDesignInput,
  validateBudgetInput,
  validateTencent99Input,
  validateGovtPurchaseInput,
  validatePIISafety,
};