You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter Web应用中Supabase的JSONB类型Upsert失败求助

Flutter Web 连接自托管Supabase保存调研答案失败排查与修复

问题背景

开发Flutter Web调研应用,已实现从自托管Supabase获取并展示问题,但保存答案到responses表时无任何错误提示,但Upsert操作完全不生效。

相关表结构

CREATE TABLE responses (
    id SERIAL PRIMARY KEY,
    respondent_id UUID REFERENCES respondents(id) NOT NULL,
    assessment_cycle_id INT REFERENCES assessment_cycle(id) NOT NULL,
    response_date DATE,
    answers JSONB,
    finished BOOLEAN
);

answers字段存储<int, int>键值对(问题ID -> 评分答案)。

数据模型代码

class Response {
  final int? id;
  final String respondentId;
  final int assessmentCycleId;
  final DateTime responseDate;
  final Map<int, dynamic> answers;
  final bool? finished;

  Response({
    this.id,
    required this.respondentId,
    required this.assessmentCycleId,
    required this.responseDate,
    required this.answers,
    this.finished,
  });

  factory Response.fromJson(Map<String, dynamic> json) {
    return Response(
      id: json['id'],
      respondentId: json['respondent_id'],
      assessmentCycleId: json['assessment_cycle_id'],
      responseDate: DateTime.parse(json['response_date']),
      answers: Map<int, dynamic>.from(json['answers']),
      finished: json['finished'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'respondent_id': respondentId,
      'assessment_cycle_id': assessmentCycleId,
      'response_date': responseDate.toIso8601String(),
      'answers': answers,
      'finished': finished,
    };
  }
}

仓库层Upsert代码

class ResponseRepository {
  final supabase = Supabase.instance.client;

  Future<void> upsertResponse(Response res) async {
    try {
      print(
          'Entering upsert ${res.respondentId} ${res.assessmentCycleId} ${res.answers}');

      final checkIfExist = await supabase
          .from('responses')
          .select()
          .eq('respondent_id', res.respondentId)
          .eq('assessment_cycle_id', res.assessmentCycleId);

      if (checkIfExist.isEmpty) {
        await supabase.from('responses').insert({
          'respondent_id': res.respondentId,
          'assessment_cycle_id': res.assessmentCycleId,
          'response_date': res.responseDate.toIso8601String().split('T')[0],
          'answers': res.answers,
        });
      } else {
        await supabase.from('responses').update({
          'response_date': res.responseDate.toIso8601String().split('T')[0],
          'answers': res.answers
        }).eq('id', checkIfExist[0]['id']);
      }
      /*
      final response = await supabase.from('responses').upsert({
        'respondent_id': res.respondentId,
        'assessment_cycle_id': res.assessmentCycleId,
        'response_date': res.responseDate.toIso8601String().split('T')[0],
        'answers': res.answers,
      }, onConflict: 'respondent_id,assessment_cycle_id');
     */
    } catch (error) {
      throw Exception('Error updating answers: $error');
    }
  }
}

组件调用代码片段

class _SurveyPageState extends ConsumerState<SurveyPage> {
  final assessmentCycleId = 1;
  final ResponseRepository _responseRepository = ResponseRepository();
  final Map<int, dynamic> _answers = {};

  Future<void> _fetchInitialAnswer(String respondentId) async {
    print('Fetching Initial Answer');
    try {
      Response? initialAnswer = await _responseRepository.getResponse(
          respondentId, assessmentCycleId);
      if (initialAnswer != null) {
        _answers.clear();
        _answers.addAll(initialAnswer.answers);
      }
    } catch (error) {
      const SnackBar(
        content: Text('Unexpected Error in fetching saved answers'),
      );
    }
  }

  Future<void> saveAnswers() async {
    try {
      Response res = Response(
          respondentId: respondentId,
          assessmentCycleId: assessmentCycleId,
          responseDate: DateTime.now(),
          answers: _answers);

      await _responseRepository.upsertResponse(res);
    } catch (error) {
      const SnackBar(
        content: Text('Unexpected Error in saving answers'),
      );
    }
  }

  // ... 其他组件代码
}

修复步骤

1. 检查并配置Supabase行级安全(RLS)策略

自托管Supabase默认会对新表启用RLS,若没有配置允许插入/更新的策略,请求会静默失败(无错误提示)。

临时验证方案

先关闭responses表的RLS,测试是否能正常保存:

ALTER TABLE responses DISABLE ROW LEVEL SECURITY;

若能正常保存,说明是RLS的问题,需要添加正确的策略:

-- 允许用户插入自己的响应
CREATE POLICY "Allow respondents to insert their own responses"
ON responses FOR INSERT
WITH CHECK (auth.uid() = respondent_id);

-- 允许用户更新自己的响应
CREATE POLICY "Allow respondents to update their own responses"
ON responses FOR UPDATE
USING (auth.uid() = respondent_id);

注:若respondent_id不是当前用户的UUID,需根据实际业务调整策略逻辑。

2. 修复JSONB字段的序列化问题

Flutter中Map<int, dynamic>序列化为JSON时,int类型的键会被自动转为字符串,但为了避免潜在兼容问题,建议显式转换:

修改仓库层的upsert代码,将answers转换为Map<String, dynamic>:

// 插入时
'answers': res.answers.map((key, value) => MapEntry(key.toString(), value)),

// 更新时同理
'answers': res.answers.map((key, value) => MapEntry(key.toString(), value)),

或者修改Response模型的toJson方法:

Map<String, dynamic> toJson() {
  return {
    'id': id,
    'respondent_id': respondentId,
    'assessment_cycle_id': assessmentCycleId,
    'response_date': responseDate.toIso8601String().split('T')[0], // 直接生成DATE格式字符串
    'answers': answers.map((key, value) => MapEntry(key.toString(), value)),
    'finished': finished,
  };
}

3. 启用原生Upsert并添加联合唯一约束

原注释中的Upsert方法无效,核心原因是respondent_id和assessment_cycle_id没有设置联合唯一约束,Supabase无法识别冲突条件。

第一步:添加联合唯一约束

执行SQL:

ALTER TABLE responses ADD CONSTRAINT unique_respondent_cycle UNIQUE (respondent_id, assessment_cycle_id);

第二步:修复仓库层的Upsert代码

替换原有的查询+判断逻辑,使用原生Upsert:

Future<void> upsertResponse(Response res) async {
  try {
    print('Entering upsert ${res.respondentId} ${res.assessmentCycleId} ${res.answers}');
    
    await supabase.from('responses').upsert(
      {
        'respondent_id': res.respondentId,
        'assessment_cycle_id': res.assessmentCycleId,
        'response_date': res.responseDate.toIso8601String().split('T')[0],
        'answers': res.answers.map((key, value) => MapEntry(key.toString(), value)),
      },
      onConflict: 'respondent_id,assessment_cycle_id',
      update: { // 明确指定更新字段,避免覆盖其他字段
        'response_date': res.responseDate.toIso8601String().split('T')[0],
        'answers': res.answers.map((key, value) => MapEntry(key.toString(), value)),
      },
    );
  } catch (error) {
    print('Upsert error: $error'); // 打印具体错误
    throw Exception('Error updating answers: $error');
  }
}

原生Upsert避免了先查询再操作的竞态条件,效率更高。

4. 增强错误处理与日志

原组件中的错误处理没有显示SnackBar,且没有打印具体错误,导致无法定位问题:

修改saveAnswers方法:

Future<void> saveAnswers() async {
  try {
    Response res = Response(
        respondentId: respondentId,
        assessmentCycleId: assessmentCycleId,
        responseDate: DateTime.now(),
        answers: _answers);

    await _responseRepository.upsertResponse(res);
    // 可选:保存成功提示
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('答案已保存')),
      );
    }
  } catch (error) {
    print('Save answers error: $error');
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('保存失败: ${error.toString()}')),
      );
    }
  }
}

同时在仓库层的catch块中打印错误,方便调试。

5. 验证DateTime格式

确保response_date是正确的DATE格式(YYYY-MM-DD),原代码中的split('T')[0]是正确的,也可以直接使用DateFormat('yyyy-MM-dd').format(res.responseDate)(需要导入intl包)更直观。


内容的提问来源于stack exchange,提问作者Sheenergizer

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 08:15:54