免费数据库、免费图床搭建属于自己的壁纸库(flutter)
使用 Flutter 直连免费数据库 + 免费图床搭建壁纸库的方案,其核心是省去后端服务器,通过客户端直接客户端直接与数据库交互 + 图床存储图片资源,这种架构的优缺点如下:
优点
- 成本极低:零服务器 / 域名开销,依赖免费数据库 + 图床快速启动。
- 开发高效:省去后端接口开发,Flutter 直连数据库,减少联调环节。
- 部署简单:无需配置服务器,仅打包 Flutter 客户端即可,维护量小。
- 适配小型场景:满足个人收藏、小众壁纸库等低用户量需求。
缺点
- 安全高危:数据库账号密码易被反编译提取,易遭篡改、注入攻击。
- 资源受限:免费服务有并发、存储、请求频率配额,超量即限流 / 停用。
- 扩展性差:无缓存层,用户增长后易卡顿;无法实现复杂业务逻辑(如权限控制)。
- 依赖风险:第三方免费服务可能停服、改规则,导致壁纸库失效。
- 维护困难:数据库变更需同步修改客户端,无日志难排查问题。
1、数据库 SVNColud
SVNColud 专业SVN服务、代码托管、数据库托管。注册登录即可,注册即是vip用户,vip用户提供 5m 的免费数据库。
1.1 创建数据库
1.2 连接数据库
随便什么软件连接都行,按照官网给的教程即可。我用的是 Dbeaver,你也可以用Navicat。
1.3 建表
这是我自己根据官方文档创建的数据库,创建了一张简单的表来存储图片地址信息。
根据已用空间估算,5M 大概能存1万条数据左右(图片地址越长空间用的越多),对于自己的壁纸库,这个数量够用了,具体数据如下:
2、后台管理系统
2.1 前端界面
随便用vue或者react写一个界面,包括增删改查即可。如下:
2.1 后端接口
后台的接口只是自己用,也不需要给别人调用,用 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 拼接上路径即可。
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数据无法使用和中文乱码问题。
大致结构如下:
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端也能正常使用。