feat: migrate to PostgreSQL GORM and MinIO file storage

Use UUID models and soft deletion, persist file relationships with deduplicated monthly MinIO objects, and add admin file management. Update mini-program media handling and add local dev configuration.
This commit is contained in:
2026-11-01 11:48:45 +08:00
parent 0530523750
commit 4934a69581
94 changed files with 4686 additions and 2147 deletions
+43
View File
@@ -0,0 +1,43 @@
// File IDs are persisted; stable API URLs are used for display only.
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
function fileId(value) {
if (value && typeof value === 'object') return fileId(value.file_id || value.fileId || (value.file && value.file.id) || value.id || value.path || value.url);
if (typeof value !== 'string') return '';
if (new RegExp('^' + UUID + '$', 'i').test(value)) return value;
var match = value.match(new RegExp('^(?:https?://[^/]+)?/api/files/(' + UUID + ')(?:[?#].*)?$', 'i'));
return match ? match[1] : '';
}
function uploadReference(value) {
if (!value || value.error) throw value || new Error('上传失败');
var id = fileId(value);
if (!id) throw new Error('上传响应缺少文件 ID,请重试');
return id;
}
function normalizeUploadResult(value) {
if (!value || typeof value !== 'object' || value.error) return value;
var id = fileId(value);
if (!id) return value;
return Object.assign({}, value, { file_id: id, path: '/api/files/' + id, url: '/api/files/' + id });
}
function filePayload(action, data) {
if (!data || typeof data !== 'object') return data;
var mappings = {
updateProfile: { avatar: 'avatar_file_id' }, updatePartnerProfile: { avatar: 'avatar_file_id' },
albumAdd: { cover: 'cover_file_id' }, albumUpdate: { cover: 'cover_file_id' },
photoAdd: { path: 'file_id' }, diaryAdd: { images: 'image_file_ids' }, diaryUpdate: { images: 'image_file_ids' },
timelineAdd: { bg_image: 'bg_image_file_id', images: 'image_file_ids' },
timelineUpdate: { bg_image: 'bg_image_file_id', images: 'image_file_ids' }, checkinDo: { photo: 'photo_file_id' }
};
var out = Object.assign({}, data);
Object.keys(mappings[action] || {}).forEach(function(key) {
if (!Object.prototype.hasOwnProperty.call(out, key)) return;
var target = mappings[action][key];
var value = out[key];
// Legacy references remain compatible; all new uploads supply UUIDs.
var convert = function(v) { return fileId(v) || v; };
if (!Object.prototype.hasOwnProperty.call(out, target)) out[target] = Array.isArray(value) ? value.map(convert) : convert(value);
delete out[key];
});
return out;
}
module.exports = { fileId, uploadReference, normalizeUploadResult, filePayload };
+15 -14
View File
@@ -1,22 +1,23 @@
const { fileId, normalizeUploadResult, filePayload } = require('./file-media');
const { camelName, toWire, fromWire } = require('./api-naming');
const dataCache = require('./data-cache');
/**
* 网络请求工具 - 对接后端 API
*/
const API_BASE = 'https://past.sundynix.cn/api';
const MEDIA_BASE = 'https://past.sundynix.cn/backend/';
const { API_BASE } = require('../config/env');
/**
* 将相对路径转为完整URL(供小程序端显示图片使用)
* @param {string} path - 相对路径,如 "uploads/avatars/xxx.jpg"
* @returns {string} 完整URL,如 "http://192.168.2.103/uploads/avatars/xxx.jpg"
* 将文件 UUID 或 /api/files/{id} 转成完整访问地址。
* 完整 URL 和小程序本地图片路径直接使用。
*/
function getImageUrl(path) {
if (!path) return '';
if (typeof path === 'object') path = fileId(path) || path.path || path.url || '';
if (typeof path !== 'string') return '';
if (path.startsWith('/api/files/')) return API_BASE.replace(/\/api$/, '') + path;
if (fileId(path) === path) return API_BASE + '/files/' + path;
if (path.startsWith('http://') || path.startsWith('https://')) return path;
if (path.startsWith('/backend/')) return MEDIA_BASE + path.substring('/backend/'.length);
if (path.startsWith('/')) return MEDIA_BASE + path.substring(1);
return MEDIA_BASE + path;
return path;
}
/**
@@ -26,23 +27,23 @@ function getImageUrl(path) {
*/
function parseUploadResponse(raw) {
if (!raw) return null;
if (typeof raw === 'object') return fromWire(raw);
if (typeof raw === 'object') return normalizeUploadResult(fromWire(raw));
var str = String(raw).trim();
// 去除 BOM 和不可见字符
str = str.replace(/^\uFEFF/, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
try {
return fromWire(JSON.parse(str));
return normalizeUploadResult(fromWire(JSON.parse(str)));
} catch(e) {
// 尝试提取 JSON 片段(Android 端可能返回带额外字符的响应)
var match = str.match(/\{[\s\S]*\}/);
if (match) {
try { return fromWire(JSON.parse(match[0])); } catch(e2) {}
try { return normalizeUploadResult(fromWire(JSON.parse(match[0]))); } catch(e2) {}
}
// 最后尝试:查找 "success" 或 "path" 关键词,手动构建响应对象
if (str.indexOf('success') > -1 || str.indexOf('path') > -1) {
var pathMatch = str.match(/"path"\s*:\s*"([^"]+)"/);
if (pathMatch) {
return { success: true, path: pathMatch[1] };
return normalizeUploadResult({ success: true, path: pathMatch[1] });
}
}
return null;
@@ -129,7 +130,7 @@ function sendRequest(action, data = null, method = 'POST') {
}
let url = API_BASE + '/' + encodeURIComponent(camelName(action));
let requestData = toWire(data);
let requestData = toWire(filePayload(camelName(action), data));
if (method === 'GET' && data) {
const params = [];
@@ -385,7 +386,7 @@ const api = {
songsDelete: (data) => request('songsDelete', data),
// 打卡
smokingInfo: (userId, data = {}) => request("smokingInfo", { page: 1, page_size: 20, ...data, user_id: userId || 0 }, "GET"),
smokingInfo: (userId, data = {}) => request("smokingInfo", { page: 1, page_size: 20, ...data, user_id: userId || "" }, "GET"),
smokingAction: (data) => request("smokingAction", data),
checkinList: (data = {}) => request('checkinList', { page: 1, page_size: 20, ...data }, 'GET'),
checkinAdd: (data) => request('checkinAdd', data),