因 LeanCloud 停止对外服务,对数据迁移至 Vercel 。
发表于:2026-03-25 | 分类: 折腾
字数统计: 1.7k | 阅读时长: 8分钟 | 阅读量:

前言

相信大家都知道了,LeanCloud 将于 2027 年 1 月 12 日停止对外提供服务,本站有俩个服务依赖着 LeanCloud 服务,今天有空把它们都迁移一下。

一. Do you like me 小组将

1. 部署

  1. 单击此处 一键部署 Deploy with Vercel
    项目地址https://github.com/5ime/likeMe
    一直下一步就行了

  2. 成功后去仪表盘,切换至 Storage 创建数据库并连接。
    选择Storage
    创建数据库
    起名字

  3. 连接成功去检查环境变量,选择项目的 Project Settings 找到 Environment Variables 检查环境变量是否存在 POSTGRES_URL
    为项目绑定刚刚创建的数据库
    检查环境变量

  4. 重新部署。
    右上角三个点

  5. 检验是否数据库连接成功。

    1
    2
    3
    4
    5
    6
    7
    8
    最简单的方式:访问一次接口触发建表,然后再看是否能正常返回。

    GET /healthz:服务是否存活(返回 ok)
    GET /info:会读取数据库并返回当前点赞数
    如果数据库连不上,你通常会在 Vercel 的 Logs 里看到与连接/鉴权相关的报错(例如环境变量缺失、连接串无效等)。

    本项目会在首次请求时自动建表(like_count / like_user),无需手动建表。

  6. 至此部署部分就应该结束了,以下是我的示例 Demo 。
    Demo

  7. 用法请参考之前的文章。

  8. 题外话,这里为了适配主题的 pjax 我对 likeme.js 进行修改,以便切换页面时失效。

    1
    const likeMe=(()=>{let instance;let initialCallOptions=null;let retryCount=0;const MAX_RETRIES=20;const RETRY_INTERVAL=50;function tryInitializeInstance(options){const targetElement=document.querySelector(options.el);if(targetElement){instance=new LikeMeSingleton(options);instance.reinitialize(options.el);initialCallOptions=null;retryCount=0}else if(retryCount<MAX_RETRIES){console.warn(`LikeMe: Target element ${options.el} not found yet. Retrying in ${RETRY_INTERVAL}ms (attempt ${retryCount+1}/${MAX_RETRIES}).`);retryCount++;setTimeout(()=>tryInitializeInstance(options),RETRY_INTERVAL)}else{console.error(`LikeMe: Target element ${options.el} not found after ${MAX_RETRIES} retries. Initialization failed.`);initialCallOptions=null;retryCount=0}}return options=>{if(!instance){if(!initialCallOptions){initialCallOptions=options};tryInitializeInstance(initialCallOptions)}else{instance.url=options.serverURL||instance.url;instance.color=options.color||instance.color;instance.reinitialize(options.el)}return instance}})();class LikeMeSingleton{constructor({el,serverURL,color='#ff9797'}){Object.assign(this,{el,url:serverURL,color});this.isLiking=false}async reinitialize(newEl=this.el){this.el=newEl;const currentElement=document.querySelector(this.el);if(currentElement){currentElement.innerHTML=''}else{console.warn(`LikeMe target element ${this.el} not found during reinitialization. Skipping render.`);return}try{await this.renderUI();this.attachEvents();this.updateCount()}catch(error){console.error('LikeMe reinitialize error:',error)}}async init(){return this.reinitialize(this.el)}async renderUI(){try{const response=await fetch(this.url);const html=await response.text();const element=document.querySelector(this.el);if(element){const parser=new DOMParser();const doc=parser.parseFromString(html,'text/html');element.innerHTML=doc.body.innerHTML;const card=element.querySelector('.likeCard');if(card){card.style.setProperty('background-color',this.color)}}}catch(error){console.error('Render UI error:',error)}}attachEvents(){const card=document.querySelector(`${this.el} .likeCard`);if(card){card.addEventListener('click',()=>this.handleLike())}}async updateCount(){try{const response=await fetch(`${this.url}/info`);const data=await response.json().catch(()=>null);if(response.ok&&data){const count=data.data?.count||0;const textElement=document.querySelector(`${this.el} .likeCard-text`);if(textElement){textElement.textContent=`❤ ${count}`}}}catch(error){console.error('Update count error:',error)}}setUIState(isLoading,text,isSuccess=false){const card=document.querySelector(`${this.el} .likeCard`);const textElement=document.querySelector(`${this.el} .likeCard-text`);if(card&&textElement){card.classList.remove('loading','success');if(isLoading){card.classList.add('loading')}else if(isSuccess){card.classList.add('success')}textElement.textContent=text}}async handleLike(){if(this.isLiking)return;this.isLiking=true;this.setUIState(true,'❤ 爱意传递中...');try{const response=await fetch(`${this.url}/like`);const payload=await response.json().catch(()=>null);if(!payload){this.setUIState(false,'❤ 网络错误');setTimeout(()=>{this.updateCount()},1500);return}const{code,data={},msg}=payload;const count=data.count||0;if(response.ok&&code==='200'){this.setUIState(false,'❤ 传递成功~',true);setTimeout(()=>{this.setUIState(false,`❤ ${count}`)},500);return}this.setUIState(false,msg||'❤ 传递失败~');setTimeout(()=>{this.updateCount()},1500)}catch(error){console.error('Handle like error:',error);this.setUIState(false,'❤ 网络错误');setTimeout(()=>{this.updateCount()},1500)}finally{this.isLiking=false}}}

2. 数据迁移

  1. 导出数据。
    去LeanCloud导出所有数据并下载

  2. 连接数据库(我这里以 Navicat 为例),你也可以用Neon自带的进行操作。
    输入主机等连接数据库

  3. 打开 likeCount 这个表来修改数据,这个数据从刚刚导出的文件中 likeCount.0.jsonl 查看,修改成一样的数值。
    迁移数量成功

  4. 进阶,把之前的ip地址和时间也直接导入。

    • 首先,先了解一下数据的结构。
      LeanCloud
      1
      {"ACL":{"*":{"read":true}},"ip":"0.0.0.0","createdAt":"2024-11-06T05:24:53.869Z","updatedAt":"2024-11-06T05:24:53.869Z","objectId":"672afda5ff1aa61512697fa9"}
    • 这是用ai生产的一个转换成sql语句的脚本。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    const fs = require('fs');
    const readline = require('readline');

    // 定义输入和输出文件
    const inputFilePath = 'likeUser.0.jsonl';
    const outputFilePath = 'insert_like_user.sql';

    // 创建可读流和可写流
    const readStream = fs.createReadStream(inputFilePath);
    const writeStream = fs.createWriteStream(outputFilePath);

    const rl = readline.createInterface({
    input: readStream,
    crlfDelay: Infinity // 检测所有CRLF行尾
    });


    let lineNumber = 0;

    rl.on('line', (line) => {
    lineNumber++;
    // 跳过文件头注释行和空行
    if (line.startsWith('#') || line.trim() === '') {
    return;
    }

    try {
    const data = JSON.parse(line);

    const ip = data.ip;
    const createdAt = data.createdAt; // e.g., "2024-11-06T05:24:53.869Z"

    // 从 createdAt 中提取日期部分作为 'day'
    const dateObj = new Date(createdAt);
    const day = dateObj.toISOString().split('T')[0]; // YYYY-MM-DD

    // 构建 SQL INSERT 语句,使用 ON CONFLICT DO NOTHING
    const sql = `INSERT INTO "public"."like_user" ("ip", "day", "created_at") VALUES ('${ip}', '${day}', '${createdAt}') ON CONFLICT ("ip", "day") DO NOTHING;\n`;
    writeStream.write(sql);

    } catch (error) {
    console.error(`Error parsing JSON on line ${lineNumber}: ${line}`);
    console.error(error);
    }
    });

    rl.on('close', () => {
    console.log(`Conversion complete. SQL INSERT statements (ignoring duplicates) written to ${outputFilePath}`);
    writeStream.end(); // 关闭输出文件流
    });

    readStream.on('error', (err) => {
    console.error(`Error reading input file: ${err.message}`);
    });

    writeStream.on('error', (err) => {
    console.error(`Error writing output file: ${err.message}`);
    });

    • 将脚本的生产出来的文件打开,复制所有到新建查询里并运行,你也可以用Neon自带的进行操作。
      285条ip应总数数量285
      可以用Neon自带的进行操作

    • 至此,完成所有数据迁移

二. Waline 评论

  1. 去Waline的管理后台将所有数据导出。导出数据
  2. 按照官方文档进行重新部署。
  3. 导入刚刚导出来的数据。
    导入数据
  4. 最后,记得将之前的项目中的环境变量全部移至新项目中,可以使用命令更快。

结尾

至此,教程结束。希望对你有所帮助,有任何问题请在下方留言。可以关注我的 公众号以及订阅我的文章 ,感谢你的支持,是对我最大的动力,当然了,更多的是因为热爱。

本文参考

上一篇:
放学打球
下一篇:
2025:得与失