feat: 为减少服务端出口流量,新增边缘函数检查数据库表状态
This commit is contained in:
32
README.md
32
README.md
@@ -81,6 +81,38 @@
|
|||||||
|
|
||||||
5. 目前项目用到的 sql 语句,查看项目 supabase.sql 文件。
|
5. 目前项目用到的 sql 语句,查看项目 supabase.sql 文件。
|
||||||
|
|
||||||
|
### Supabase Edge Functions 配置说明
|
||||||
|
|
||||||
|
本项目使用 Supabase Edge Functions 来检查用户配置状态。需要部署以下边缘函数:
|
||||||
|
|
||||||
|
#### 1. 边缘函数文件位置
|
||||||
|
边缘函数代码位于 `doc/edgeFunctions/*`
|
||||||
|
|
||||||
|
#### 2. 部署步骤
|
||||||
|
|
||||||
|
1. **新建边缘函数代码**:
|
||||||
|
位置在 Supabase控制台 → Edge Functions
|
||||||
|
|
||||||
|
2. **复制边缘函数代码**:
|
||||||
|
将 `doc/edgeFunctions/*` 复制到 `supabase/functions/*`。复制成功后即可点击部署
|
||||||
|
|
||||||
|
#### 3. 边缘函数说明
|
||||||
|
|
||||||
|
**check-data** - 检查用户配置状态
|
||||||
|
- **功能**:检查用户在 `user_configs` 表中的配置状态
|
||||||
|
- **认证**:需要用户 JWT token
|
||||||
|
- **返回状态**:
|
||||||
|
- `not_found`:用户配置记录不存在
|
||||||
|
- `empty`:用户配置记录存在但数据为空
|
||||||
|
- `found`:用户配置记录存在且有有效数据
|
||||||
|
|
||||||
|
#### 4. 环境变量要求
|
||||||
|
|
||||||
|
边缘函数需要以下 Supabase 环境变量(自动注入,无需手动配置):
|
||||||
|
- `SUPABASE_URL`:Supabase 项目 URL
|
||||||
|
- `SUPABASE_ANON_KEY`:Supabase 匿名公钥
|
||||||
|
- `SUPABASE_SERVICE_ROLE_KEY`:Supabase Service Role Key(用于绕过 RLS 查询)
|
||||||
|
|
||||||
更多 Supabase 相关内容查阅官方文档。
|
更多 Supabase 相关内容查阅官方文档。
|
||||||
|
|
||||||
### 构建与部署
|
### 构建与部署
|
||||||
|
|||||||
31
app/page.jsx
31
app/page.jsx
@@ -3179,13 +3179,13 @@ export default function HomePage() {
|
|||||||
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data: checkResult, error: checkError } = await supabase.functions.invoke(`check-data?userId=${userId}`, {
|
||||||
.from('user_configs')
|
method: 'GET',
|
||||||
.select('id, data, updated_at')
|
});
|
||||||
.eq('user_id', userId)
|
|
||||||
.maybeSingle();
|
if (checkError) throw checkError;
|
||||||
if (error) throw error;
|
|
||||||
if (!data?.id) {
|
if (checkResult.status === 'not_found') {
|
||||||
const { error: insertError } = await supabase
|
const { error: insertError } = await supabase
|
||||||
.from('user_configs')
|
.from('user_configs')
|
||||||
.insert({ user_id: userId });
|
.insert({ user_id: userId });
|
||||||
@@ -3193,19 +3193,30 @@ export default function HomePage() {
|
|||||||
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (checkResult.status === 'empty') {
|
||||||
|
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_configs')
|
||||||
|
.select('id, data, updated_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) {
|
if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) {
|
||||||
const localPayload = collectLocalPayload();
|
const localPayload = collectLocalPayload();
|
||||||
const localComparable = getComparablePayload(localPayload);
|
const localComparable = getComparablePayload(localPayload);
|
||||||
const cloudComparable = getComparablePayload(data.data);
|
const cloudComparable = getComparablePayload(data.data);
|
||||||
|
|
||||||
if (localComparable !== cloudComparable) {
|
if (localComparable !== cloudComparable) {
|
||||||
// 如果数据不一致
|
|
||||||
if (checkConflict) {
|
if (checkConflict) {
|
||||||
// 只有明确要求检查冲突时才提示(例如刚登录时)
|
|
||||||
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data });
|
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 否则直接覆盖本地(例如已登录状态下的刷新)
|
|
||||||
await applyCloudConfig(data.data, data.updated_at);
|
await applyCloudConfig(data.data, data.updated_at);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
125
doc/edgeFunctions/check-data.ts
Normal file
125
doc/edgeFunctions/check-data.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* 检测用户配置是否存在
|
||||||
|
*/
|
||||||
|
// supabase/functions/check-data/index.ts
|
||||||
|
|
||||||
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(async (req: Request) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Method not allowed" }),
|
||||||
|
{ status: 405, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get("Authorization");
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing authorization header" }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
||||||
|
const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
|
||||||
|
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||||
|
|
||||||
|
const supabaseClient = createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
global: {
|
||||||
|
headers: { Authorization: authHeader },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: { user }, error: userError } = await supabaseClient.auth.getUser();
|
||||||
|
|
||||||
|
if (userError || !user) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invalid JWT", exists: false, status: "error" }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const userId = url.searchParams.get("userId");
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing 'userId' parameter" }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.id !== userId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Unauthorized: User ID mismatch" }),
|
||||||
|
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
const { data, error } = await supabaseAdmin
|
||||||
|
.from("user_configs")
|
||||||
|
.select("data")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error && error.code === "PGRST116") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
exists: false,
|
||||||
|
status: "not_found",
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = data?.data;
|
||||||
|
|
||||||
|
const hasValidData =
|
||||||
|
jsonData !== null &&
|
||||||
|
typeof jsonData === "object" &&
|
||||||
|
Object.keys(jsonData).length > 0;
|
||||||
|
|
||||||
|
if (hasValidData) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
exists: true,
|
||||||
|
status: "found",
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
exists: false,
|
||||||
|
status: "empty",
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error:", err);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: err.message, exists: false, status: "error" }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user