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
+15
View File
@@ -0,0 +1,15 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const loader = require('./load-module.cjs');
test('development requests and file previews use local backend, trial and release use production', () => {
for (const version of ['develop','trial','release']) {
const wx = {getAccountInfoSync:()=>({miniProgram:{envVersion:version}})};
const load = loader({wx,console});
const env = load(path.join(__dirname,'../config/env.js'));
const {API_BASE,getImageUrl} = load(path.join(__dirname,'../utils/request.js'));
assert.equal(env.env, version === 'develop' ? 'dev' : version);
assert.equal(API_BASE, version === 'develop' ? 'http://127.0.0.1:8080/api' : 'https://past.sundynix.cn/api');
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),API_BASE+'/files/6083e119-6b26-441b-8b70-44d224366238');
}
});
+59
View File
@@ -0,0 +1,59 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const createLoader = require('./load-module.cjs');
const {uploadReference} = require('../utils/file-media');
const id = '6083e119-6b26-441b-8b70-44d224366238';
const id2 = '7083e119-6b26-441b-8b70-44d224366238';
function client(extra = {}) {
const calls = [];
const load = createLoader({console, Promise, Date, wx: {
getStorageSync: () => 'token', hideLoading() {},
request(o) { calls.push(o); o.success({statusCode:200, data:{success:true}}); }, ...extra
}});
return {...load(path.join(__dirname, '../utils/request.js')), calls};
}
test('business requests persist IDs, preserve order and distinguish clear from omitted fields', async () => {
const c = client();
await c.api.diaryUpdate({id:'diary',images:['https://past.sundynix.cn/api/files/'+id, id2]});
await c.api.albumUpdate({id:'album',cover:''});
await c.api.updateProfile({nickname:'test'});
await c.api.photoAdd({album_id:'album',path:'/api/files/'+id});
await c.api.checkinDo({id:'checkin',photo:id});
assert.deepEqual(JSON.parse(JSON.stringify(c.calls[0].data)), {id:'diary',imageFileIds:[id,id2]});
assert.equal(c.calls[1].data.coverFileId,'');
assert.equal('avatarFileId' in c.calls[2].data,false);
assert.equal(c.calls[3].data.fileId,id);
assert.equal(c.calls[4].data.photoFileId,id);
});
test('duplicate and ID-only uploads keep stable display URLs and file metadata', async () => {
const c = client({compressImage(o){o.fail();},uploadFile(o){o.success({statusCode:200,data:JSON.stringify({success:true,duplicate:true,file:{id,url:'https://minio/raw-object'}})});}});
const result = await c.uploadFileCompat({url:c.API_BASE+'/upload',filePath:'http://tmp/new.jpg'});
assert.equal(uploadReference(result),id);
assert.equal(result.duplicate,true);
assert.equal(result.file.url,'https://minio/raw-object');
assert.equal(c.getImageUrl(result.path),'https://past.sundynix.cn/api/files/'+id);
assert.throws(()=>uploadReference({success:true,path:'http://tmp/new.jpg'}),/文件 ID/);
});
test('diary edit uploads HTTP temporary images, retains existing IDs and stops on upload failure', async () => {
let definition, saved, uploads=[];
const c = client();
const load = createLoader({console, Date, Promise, setTimeout(){}, Page(p){definition=p;}, wx:{showLoading(){},hideLoading(){},showToast(){}}}, {
'../../utils/request':{getImageUrl:c.getImageUrl,api:{async diaryUpdate(data){saved=data;}}}
});
load(path.join(__dirname,'../pages/diary_edit/diary_edit.js'));
const p = {...definition, data:{loading:false,form:{content:'text'},existingImages:[id],tempImages:[c.getImageUrl(id),'http://tmp/new.jpg']},setData(v){Object.assign(this.data,v);},async _uploadImage(v){uploads.push(v);return id2;}};
await p.submit();
assert.deepEqual(uploads,['http://tmp/new.jpg']);
assert.deepEqual(Array.from(saved.images),[id,id2]);
saved=null;p._uploadImage=async()=>{throw Error('network');};
await p.submit();assert.equal(saved,null);
});
test('media display keeps local assets and temporary paths without a backend directory prefix', () => {
const c = client();
assert.equal(c.getImageUrl('/images/default-avatar.png'), '/images/default-avatar.png');
assert.equal(c.getImageUrl('wxfile://tmp/photo.jpg'), 'wxfile://tmp/photo.jpg');
assert.equal(c.getImageUrl('http://tmp/photo.jpg'), 'http://tmp/photo.jpg');
assert.equal(c.getImageUrl(id), 'https://past.sundynix.cn/api/files/' + id);
});
+1 -1
View File
@@ -35,7 +35,7 @@ test('server relation changes discard cached data before another read',async()=>
await api.diaryList();await api.diaryList();assert.equal(reads,1);
await api.checkAuth();assert.equal(storage.get('coupleId'),'22');assert.equal(redirects,1);
const fresh=await api.diaryList();assert.equal(reads,2);assert.equal(fresh.items[0].title,'new space');
assert.equal(getImageUrl('/backend/uploads/a.jpg?media_sig=abc'),'https://past.sundynix.cn/backend/uploads/a.jpg?media_sig=abc');
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),'https://past.sundynix.cn/api/files/6083e119-6b26-441b-8b70-44d224366238');
});
test('settings and profile consent retain couple cache and lead into cooling',async()=>{
+1 -1
View File
@@ -25,7 +25,7 @@ test('purchase converts packs to pieces and cents; invalid quantities never subm
e.component.setData({quantity:'1.5'});await e.component.submit();assert.equal(e.calls.length,1);
});
test('undo includes the original record and fills a seven-day chart with zero days',async()=>{
const e=setup(async()=>result());e.component.undo({currentTarget:{dataset:{id:17}}});await Promise.resolve();await Promise.resolve();assert.equal(e.calls[0].kind,'undo');assert.equal(e.calls[0].original_id,17);assert.equal(e.component.data.daily.length,7);
const e=setup(async()=>result());e.component.undo({currentTarget:{dataset:{id:'6083e119-6b26-441b-8b70-44d224366238'}}});await Promise.resolve();await Promise.resolve();assert.equal(e.calls[0].kind,'undo');assert.equal(e.calls[0].original_id,'6083e119-6b26-441b-8b70-44d224366238');assert.equal(e.component.data.daily.length,7);
});
test('string zero is active while one is undone, and ledger identity follows the selected account',async()=>{