免费数据库、免费图床搭建属于自己的壁纸库(flutter)

169 阅读9分钟

免费数据库、免费图床搭建属于自己的壁纸库(flutter)

使用 Flutter 直连免费数据库 + 免费图床搭建壁纸库的方案,其核心是省去后端服务器,通过客户端直接客户端直接与数据库交互 + 图床存储图片资源,这种架构的优缺点如下:

优点

  1. 成本极低:零服务器 / 域名开销,依赖免费数据库 + 图床快速启动。
  2. 开发高效:省去后端接口开发,Flutter 直连数据库,减少联调环节。
  3. 部署简单:无需配置服务器,仅打包 Flutter 客户端即可,维护量小。
  4. 适配小型场景:满足个人收藏、小众壁纸库等低用户量需求。

缺点

  1. 安全高危:数据库账号密码易被反编译提取,易遭篡改、注入攻击。
  2. 资源受限:免费服务有并发、存储、请求频率配额,超量即限流 / 停用。
  3. 扩展性差:无缓存层,用户增长后易卡顿;无法实现复杂业务逻辑(如权限控制)。
  4. 依赖风险:第三方免费服务可能停服、改规则,导致壁纸库失效。
  5. 维护困难:数据库变更需同步修改客户端,无日志难排查问题。

1、数据库 SVNColud

SVNColud 专业SVN服务、代码托管、数据库托管。注册登录即可,注册即是vip用户,vip用户提供 5m 的免费数据库。

image.png

1.1 创建数据库

image.png image.png

1.2 连接数据库

随便什么软件连接都行,按照官网给的教程即可。我用的是 Dbeaver,你也可以用Navicat。

image.png

1.3 建表

这是我自己根据官方文档创建的数据库,创建了一张简单的表来存储图片地址信息。

image.png

根据已用空间估算,5M 大概能存1万条数据左右(图片地址越长空间用的越多),对于自己的壁纸库,这个数量够用了,具体数据如下:

image.png

2、后台管理系统

2.1 前端界面

随便用vue或者react写一个界面,包括增删改查即可。如下:

image.png

2.1 后端接口

image.png

后台的接口只是自己用,也不需要给别人调用,用 nodejs 快速写一下即可,如果你想给其他人用,可以通过flutter实现,下面有介绍怎么使用。

用到的包:package.json

{
  "name": "bocchi-backend",
  "version": "1.0.0",
  "description": "Basic Node.js backend with MySQL database connection",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "mysql2": "^3.6.0",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}

入口文件:index.js

const express = require('express');
const cors = require('cors');
require('dotenv').config();

const { testConnection } = require('./db/connection');
const apiRoutes = require('./routes');

const app = express();
const PORT = process.env.PORT || 3000;

// 中间件
app.use(cors()); // 启用CORS
app.use(express.json()); // 解析JSON请求体
app.use(express.urlencoded({ extended: true })); // 解析URL编码的请求体

// 路由
app.use('/', apiRoutes);

// 错误处理中间件
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({
    message: '服务器内部错误',
    error: process.env.NODE_ENV === 'development' ? err.message : {}
  });
});

// 404处理
app.use((req, res) => {
  res.status(404).json({
    message: '请求的资源不存在'
  });
});

// 启动服务器
async function startServer() {
  try {
    // 测试数据库连接
    await testConnection();

    // 启动服务器
    app.listen(PORT, () => {
      console.log(`服务器正在运行,端口: ${PORT}`);
      console.log(`访问地址: http://localhost:${PORT}`);
    });
  } catch (error) {
    console.error('服务器启动失败:', error);
    process.exit(1);
  }
}

// 启动应用
startServer();

配置文件:.env这个数据库 用户名和数据库名称要一致才能成功连接。

# Database Configuration
DB_HOST= 你的数据库地址
DB_PORT=3306
DB_USER= 用户名
DB_PASSWORD= 数据库密码
DB_NAME= 数据库名称(和用户名一致即可)

# Server Configuration 启动端口
PORT=3000

数据库连接:db/connection.js

const mysql = require('mysql2/promise');
require('dotenv').config();

// 创建数据库连接池
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

// 测试数据库连接
async function testConnection() {
  try {
    const connection = await pool.getConnection();
    console.log('数据库连接成功!');
    connection.release();
  } catch (error) {
    console.error('数据库连接失败:', error);
  }
}

// 导出连接池和测试函数
module.exports = {
  pool,
  testConnection
};

总接口:routes/index.js

const express = require('express');
const router = express.Router();

// 导入路由模块
const wallpaperRoutes = require('./wallpaper');

// 首页路由
router.get('/', (req, res) => {
  res.json({
    message: '欢迎使用Bocchi后端API',
    version: '1.0.0',
    endpoints: {
      root: '/',
      health: '/health',
      api: '/api',
      wallpaper: {
        count: '/api/wallpaper/count',
        list: '/api/wallpaper'
      }
    }
  });
});

// 健康检查路由
router.get('/health', (req, res) => {
  res.status(200).json({
    status: 'OK',
    timestamp: new Date().toISOString()
  });
});

// API路由
router.get('/api', (req, res) => {
  res.json({
    message: '这是API根路径',
    endpoints: {
      wallpaper: {
        count: '/api/wallpaper/count',
        list: '/api/wallpaper'
      }
    }
  });
});

// 使用wallpaper路由
router.use('/api/wallpaper', wallpaperRoutes);

module.exports = router;

图片管理接口:routes/wallpaper.js

const express = require('express');
const router = express.Router();
const { pool } = require('../db/connection');

// 获取wallpaper表的数据总数
router.get('/count', async (req, res) => {
  try {
    const [rows] = await pool.query('SELECT COUNT(*) as total FROM wallpaper');
    res.json({
      success: true,
      data: {
        total: rows[0].total
      }
    });
  } catch (error) {
    console.error('获取wallpaper表数据总数失败:', error);
    res.status(500).json({
      success: false,
      message: '获取数据失败',
      error: error.message
    });
  }
});

// 获取wallpaper表的所有数据(分页)
router.get('/getList', async (req, res) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
    const offset = (page - 1) * limit;
    const q = req.query.q || '';
    const type = req.query.type || '';
    const order = req.query.order === "ASC" ? "ASC" : "DESC"; // 默认降序 

    // 获取总数
    const [countRows] = await pool.query(
      `
      SELECT COUNT(*) as total FROM wallpaper
      WHERE 
        (tags LIKE CONCAT('%', ?, '%') OR title LIKE CONCAT('%', ?, '%'))
      AND type LIKE CONCAT('%', ?, '%')
      `,
      [q, q, type]
    );
    const total = countRows[0].total;

    // 获取分页数据
    const sql = order === "ASC" ?
      `SELECT * FROM wallpaper
    WHERE 
      (tags LIKE CONCAT('%', ?, '%') OR title LIKE CONCAT('%', ?, '%'))
    AND type LIKE CONCAT('%', ?, '%')
    ORDER BY createtime ASC
    LIMIT ? OFFSET ?` :
      `SELECT * FROM wallpaper
    WHERE 
      (tags LIKE CONCAT('%', ?, '%') OR title LIKE CONCAT('%', ?, '%'))
    AND type LIKE CONCAT('%', ?, '%')
    ORDER BY createtime DESC
    LIMIT ? OFFSET ?`;
    const [dataRows] = await pool.query(
      sql,
      [q, q, type, limit, offset,]
    );

    res.json({
      success: true,
      data: {
        items: dataRows,
        pagination: {
          total,
          page,
          limit,
          totalPages: Math.ceil(total / limit)
        }
      }
    });
  } catch (error) {
    console.error('获取wallpaper数据失败:', error);
    res.status(500).json({
      success: false,
      message: '获取数据失败',
      error: error.message
    });
  }
});

// 新增wallpaper数据
router.post('/add', async (req, res) => {
  try {
    const { title, url, cover, tags, type, width, height } = req.body;
    const [result] = await pool.query(
      'INSERT INTO wallpaper (title, url, cover, tags, type, width, height) VALUES (?, ?, ?, ?, ?, ?, ?)'
      , [title, url, cover, tags, type, width, height]);
    res.json({
      success: true,
      data: {
        id: result.insertId
      }
    });
  } catch (error) {
    console.error('新增wallpaper数据失败:', error);
    res.status(500).json({
      success: false,
      message: '新增数据失败',
      error: error.message
    });
  }
}
);

// 根据 id 更新数据
router.post('/update', async (req, res) => {
  try {
    const { id, title, url, cover, tags, type, width, height } = req.body;
    const result = await pool.query(
      'UPDATE wallpaper SET title = ?, url = ?, cover = ?, tags = ?, type = ?, width = ?, height = ? WHERE id = ?',
      [title, url, cover, tags, type, width, height, id]
    )

    res.json({
      success: true,
      message: '更新成功'
    });

  } catch (error) {
    console.error('更新失败:', error);
    res.status(500).json({
      success: false,
      message: '更新失败',
      error: error.message
    });
  }
});

// 根据 id 删除数据
router.get('/delete', async (req, res) => {
  try {
    const { id } = req.query;
    await pool.query('DELETE FROM wallpaper WHERE id = ?', [id]);
    res.json({
      success: true,
      message: '删除成功'
    });
  } catch (error) {
    console.error('删除失败:', error);
    res.status(500).json({
      success: false,
      message: '删除失败',
      error: error.message
    });
  }
});

module.exports = router;

接口启动和使用

有兴趣的可以自己写一下,懒得写的话,按照目录结构复制一下代码,配置文件改成你自己的,命令行运行npm run start 或者 npm run dev 都行,想让他一直运行的话,安装一下 pm2 命令,然后 pm2 start 入口文件即可,即 pm2 start index.js

http://localhost:3000/api/wallpaper/getList ,使用的话加上路径前缀即 /api/wallpaper 拼接上路径即可。

image.png

3、图床

3.1 Remit.ee

Remit.ee,专业免费图床 - 在线图片上传工具,我们的图片上传工具支持多种文件格式,包括JPG、PNG、GIF、WebP、PDF等,单文件最大支持50MB。采用先进的图片压缩技术,在保证图片质量的同时显著提升加载速度。
免费,有跑路的风险,因为没有注册和登录,你只能在上传成功后立即使用图片地址,页面刷新后你就再也找不到图片了,只能重新上传。当然,还有很多免费的图床,选择自己信赖的即可,如果有提供api的更好,直接写到后台里面全,省的还要手动复制。

3.2 Gitee 或者 Github

juejin.cn/post/753344… ,这篇文章提到过怎么自建。好处是完全可控,不用担心跑路问题,不过空间有大小限制。

4、flutter 直连数据库

mysql1 flutter直连数据库插件,用于连接数据库。
charset_converter 格式转换插件,解决Blob数据无法使用和中文乱码问题。
大致结构如下:

image.png

sql.dart,配置以及sql语句。

// ignore_for_file: camel_case_types

import 'package:mysql1/mysql1.dart';

class sqlQuery {
  static final dbinfo = ConnectionSettings(
    host: '你的数据库地址',
    port: 3306,
    user: '用户名',
    password: '密码',
    db: '数据库名(和用户名一致)',
  );

  // 分页查询wallpaper表数据
  static String getListDesc = '''
    SELECT * FROM wallpaper
    WHERE 
      (tags LIKE CONCAT('%', ?, '%') OR title LIKE CONCAT('%', ?, '%'))
    AND type LIKE CONCAT('%', ?, '%')
    ORDER BY createtime DESC
    LIMIT ? OFFSET ?
  ''';

  // 分页查询wallpaper表数据
  static String getListAsc = '''
    SELECT * FROM wallpaper
    WHERE 
      (tags LIKE CONCAT('%', ?, '%') OR title LIKE CONCAT('%', ?, '%'))
    AND type LIKE CONCAT('%', ?, '%')
    ORDER BY createtime ASC
    LIMIT ? OFFSET ?
  ''';

  // 分页查询wallpaper表数据 (无视频)
  static String getListDescAllNoVideo = '''
    SELECT * FROM wallpaper
    WHERE 
      (tags LIKE CONCAT('%', ?, '%') OR title LIKE CONCAT('%', ?, '%'))
    AND type != 'video'
    ORDER BY createtime DESC
    LIMIT ? OFFSET ?
  ''';

  // 分页查询wallpaper表数据 (无视频)
  static String getListAscAllNoVideo = '''
    SELECT * FROM wallpaper
    WHERE 
      (tags LIKE CONCAT('%', ?, '%') OR title LIKE CONCAT('%', ?, '%'))
    AND type != 'video'
    ORDER BY createtime ASC
    LIMIT ? OFFSET ?
  ''';

  // 查询总条数
  static String getCount = '''
      SELECT COUNT(*) as total FROM wallpaper
      WHERE 
      (tags LIKE CONCAT('%', ?, '%') OR title LIKE CONCAT('%', ?, '%'))
      AND type LIKE CONCAT('%', ?, '%')
    ''';

    static String getCountAllNoVideo = '''
      SELECT COUNT(*) as total FROM wallpaper
      WHERE 
      (tags LIKE CONCAT('%', ?, '%') OR title LIKE CONCAT('%', ?, '%'))
      AND type != 'video'
    ''';
}

bocchi_db.dart,查询函数,注意文本是无法直接使用的,需要转换。

import 'dart:convert';
import 'dart:typed_data';
import 'package:charset_converter/charset_converter.dart';
import 'package:mysql1/mysql1.dart';
import 'package:wallpaper/db/sql/sql.dart';

// 因为没有云服务器,使用本地直接连接远程数据库的方法获取数据
// 各位开发者,请务修改数据库中的内容
// 需要修改数据库中的内容,请更换成自己的数据库,(注意安全问题)
// 作者的数据库乱修改的话会关闭账号的和销毁数据库,请勿修改,谢谢!!!
// 注册地址:https://wsfdb.cn/ (免费用户5M空间可用)
// mysql1 具体使用方法请参考:https://pub.dev/packages/mysql1

// 分页查询wallpaper表数据
// 分页查询wallpaper表数据
Future getBocchiWallpapers({
  int page = 1, // 页码,默认第一页
  int pageSize = 20, // 每页条数,默认10条
  String q = '', // 搜索关键词
  String type = '', // 类型,默认全部
  String order = 'DESC', // 排序字段,默认按创建时间排序
}) async {
  try {
    List imgList = [];
    // 建立数据库连接
    final connection = await MySqlConnection.connect(sqlQuery.dbinfo);

    // 计算偏移量(分页公式:offset = (页码 - 1) * 每页条数)
    final offset = (page - 1) * pageSize;

    // 1. 查询当前页数据
    final Results dataResults;
    final Results countResults;
    if (type == '' || type == 'all' || type.isEmpty) {
      // 查询所有数据 但不包含video
      dataResults = await connection.query(
          order == "DESC"
              ? sqlQuery.getListDescAllNoVideo
              : sqlQuery.getListAscAllNoVideo,
          [q, q, pageSize, offset]);

      // 2. 查询总条数(用于计算总页数)
      countResults =
          await connection.query(sqlQuery.getCountAllNoVideo, [q, q]);
    } else {
      // 查询指定类型的数据
      dataResults = await connection.query(
          order == "DESC" ? sqlQuery.getListDesc : sqlQuery.getListAsc,
          [q, q, type, pageSize, offset]);

      countResults = await connection.query(sqlQuery.getCount, [q, q, type]);
    }

    final total = countResults.first['total'] as int;
    final totalPages = (total + pageSize - 1) ~/ pageSize; // 向上取整计算总页数

    for (var row in dataResults) {
      imgList.add({
        'id': row['id'],
        'title': _safeConvertToString(row['title']),
        'url': _safeConvertToString(row['url']),
        'cover': _safeConvertToString(row['cover']),
        'tags': _safeConvertToString(row['tags']),
        'type': _safeConvertToString(row['type']),
        'width': row['width'],
        'height': row['height'],
        'createtime': row['createtime'],
        'updatetime': row['updatetime'],
      });
    }

    // 关闭连接
    await connection.close();
    return {
      "code": 200,
      "msg": "success",
      'total': total,
      'totalPages': totalPages,
      'page': page,
      'pageSize': pageSize,
      'list': imgList,
    };
  } catch (e) {
    return {
      "code": 500,
      "msg": "服务器错误",
      "error": e.toString(),
    };
  }
}

// 核心工具函数:安全将任意类型(包括Blob)转换为String(支持中文编码)
Object? _safeConvertToString(dynamic value) {
  if (value == null) return null;

  if (value is Blob) {
    final bytes = Uint8List.fromList(value.toBytes());
    try {
      // 优先尝试UTF-8解码(标准编码)
      return utf8.decode(bytes);
    } catch (e) {
      try {
        // 尝试GBK解码(中文常见编码)
        return CharsetConverter.decode("GBK", bytes);
      } catch (e) {
        try {
          // 尝试GB2312解码(中文传统编码)
          return CharsetConverter.decode("GB2312", bytes);
        } catch (e) {
          // 所有编码都失败,返回Base64(适用于图片等二进制数据)
          return 'data:image/jpeg;base64,${base64Encode(bytes)}';
        }
      }
    }
  }

  // 处理其他类型(直接转为字符串)
  if (value is String) {
    return value;
  }
  return value.toString();
}

使用

使用的时候直接调用 getBocchiWallpapers 函数即可。亲自测试,打包后依然能用,pc端也能正常使用。

image.png

image.png