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
@@ -4,7 +4,7 @@ const cache = require('../../utils/data-cache');
const titles = {consume:'抽一支 · 打卡',purchase:'买烟',receive:'别人递烟',receive_smoke:'收到并抽一支',adjust:'调整库存',give:'递给别人一支',undo:'撤销记录'};
Component({
properties: { detailed: Boolean },
data: { loading:true, error:false, info:null, saving:false, sheet:false, mode:'purchase', quantity:'1', packSize:'20', amount:'', note:'', viewUserId:0, lastLabel:'还没有记录', daily:[], logs:[], periodStats:[], logsPage:1, logsTotal:0, logsHasMore:false, logsLoadingMore:false, logsMoreError:false, ownerName:'', ownerAvatar:'', ownerInitial:'我' },
data: { loading:true, error:false, info:null, saving:false, sheet:false, mode:'purchase', quantity:'1', packSize:'20', amount:'', note:'', viewUserId:"", lastLabel:'还没有记录', daily:[], logs:[], periodStats:[], logsPage:1, logsTotal:0, logsHasMore:false, logsLoadingMore:false, logsMoreError:false, ownerName:'', ownerAvatar:'', ownerInitial:'我' },
lifetimes: { attached(){this.load();} },
pageLifetimes: { show(){this.load();} },
methods: {
@@ -30,8 +30,8 @@ Component({
money:info[period.key+'_purchase_cents'] == null ? '—' : (Number(info[period.key+'_purchase_cents'])/100).toFixed(2)
}));
const profile=this._profile||{};
const ownerId=Number(info.user_id || this.data.viewUserId || wx.getStorageSync('userId'));
const owner=[profile.partner1,profile.partner2].find(user=>user && Number(user.id)===ownerId)||{};
const ownerId=String(info.user_id || this.data.viewUserId || wx.getStorageSync('userId') || '');
const owner=[profile.partner1,profile.partner2].find(user=>user && String(user.id)===ownerId)||{};
const ownerName=owner.nickname||info.owner_name||owner.username||(info.read_only?'TA':'我');
const ownerAvatar=getImageUrl(owner.avatar||info.owner_avatar||'');
this.setData({info,logs,daily,periodStats,logsPage:Number(info.logs_page)||1,logsTotal:Number(info.logs_total)||logs.length,logsHasMore:info.logs_has_more===true,logsLoadingMore:false,logsMoreError:false,lastLabel,ownerName,ownerAvatar,ownerInitial:ownerName.slice(0,1),error:false,loading:false});
@@ -50,7 +50,7 @@ Component({
avatarFailed(){this.setData({ownerAvatar:''});},
retry(){this.load(true);},
openDetail(){if(!this.properties.detailed)wx.navigateTo({url:'/pages/smoking-detail/smoking-detail'});},
async switchOwner(e){if(this.data.saving)return;this._logsGeneration=(this._logsGeneration||0)+1;this.setData({viewUserId:Number(e.currentTarget.dataset.user)||0,loading:true,info:null});if(this._loading)await this._loading;await this.load();},
async switchOwner(e){if(this.data.saving)return;this._logsGeneration=(this._logsGeneration||0)+1;this.setData({viewUserId:String(e.currentTarget.dataset.user || ""),loading:true,info:null});if(this._loading)await this._loading;await this.load();},
replenish(){if(this.data.saving)return;this.setData({sheet:true,mode:'purchase',quantity:'1',packSize:'20',amount:'',note:''});},
mode(e){if(this.data.saving)return;const mode=e.currentTarget.dataset.mode;this.setData({mode,quantity:mode==='adjust'?String(this.data.info.stock):'1',amount:'',note:''});},
input(e){if(!this.data.saving)this.setData({[e.currentTarget.dataset.field]:e.detail.value});},
@@ -74,7 +74,7 @@ Component({
if(amount>100000000)return wx.showToast({title:'金额超出范围',icon:'none'});
return this.act({kind:mode,quantity,amount_cents:amount,note:this.data.note.trim()});
},
undo(e){if(this.data.saving)return;wx.showModal({title:'撤销这条记录?',content:'恢复该操作前的库存变化和抽烟次数。赠出一支仅扣库存,撤销会加回一支;“收到并抽一支”会同时撤销收到和抽烟。',success:res=>{if(res.confirm)this.act({kind:'undo',original_id:Number(e.currentTarget.dataset.id),quantity:0});}});},
undo(e){if(this.data.saving)return;wx.showModal({title:'撤销这条记录?',content:'恢复该操作前的库存变化和抽烟次数。赠出一支仅扣库存,撤销会加回一支;“收到并抽一支”会同时撤销收到和抽烟。',success:res=>{if(res.confirm)this.act({kind:'undo',original_id:String(e.currentTarget.dataset.id),quantity:0});}});},
async act(payload) {
if(this.data.saving||!this.data.info||this.data.info.read_only||!this.data.info.available)return;
const signature=JSON.stringify(payload);
+13
View File
@@ -0,0 +1,13 @@
// 开发工具使用本机服务;真机调试时把 dev.apiBase 改为电脑的局域网 IP。
const environments = {
dev: { apiBase: 'http://127.0.0.1:8080/api' },
trial: { apiBase: 'https://past.sundynix.cn/api' },
release: { apiBase: 'https://past.sundynix.cn/api' }
};
let version = 'release';
try {
const account = wx.getAccountInfoSync();
version = account.miniProgram.envVersion || 'release';
} catch (e) {}
const env = version === 'develop' ? 'dev' : version === 'trial' ? 'trial' : 'release';
module.exports = { env, API_BASE: environments[env].apiBase };
+3 -2
View File
@@ -1,3 +1,4 @@
const { uploadReference } = require('../../utils/file-media');
const { itemsOf, startList, current, mergeUnique, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
const { currentUserLocation } = require('../../utils/user-location');
const { photoForm, metadataRows } = require('../../utils/photo-metadata');
@@ -168,7 +169,7 @@ Page(withDataCache(withListPagination({
(photos || []).forEach(function(p) {
var batchId = p.batch_id || p.created_at || 'default';
if (!batchMap[batchId]) {
var cid = parseInt(p.created_by) || 0;
var cid = String(p.created_by) || 0;
batchMap[batchId] = {
batch_id: batchId,
creator_name: nameMap[cid] || defaultName,
@@ -358,7 +359,7 @@ Page(withDataCache(withListPagination({
formData: { sub_dir: 'photos' }
});
// 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'}
var uploadedPath = uploadRes && (uploadRes.path || uploadRes.url || '');
var uploadedPath = uploadReference(uploadRes);
if (uploadedPath) {
await api.photoAdd({
album_id: String(albumId),
+5 -8
View File
@@ -1,3 +1,4 @@
const { uploadReference } = require('../../utils/file-media');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/checkin-detail/checkin-detail.js
@@ -14,9 +15,9 @@ Page(withDataCache({
this.setData(getLayout());
var id = options.id;
if (id) {
this.setData({ detailId: parseInt(id) });
this.setData({ detailId: String(id) });
this.loadCoupleInfo();
this.loadDetail(parseInt(id));
this.loadDetail(String(id));
}
var self = this;
var swipe = createSwipeBack(function() { wx.navigateBack(); });
@@ -191,13 +192,9 @@ Page(withDataCache({
name: 'file',
formData: { sub_dir: 'checkins' }
});
photoPath = upResult.path || '';
photoPath = uploadReference(upResult);
} catch(e) {
// 内容安全违规:抛出错误中断打卡并提示用户
if (e && e.error) {
throw e;
}
photoPath = '';
throw e;
}
wx.hideLoading();
}
+3 -2
View File
@@ -31,7 +31,7 @@ Page(withDataCache({
this.setData(getLayout());
var diaryId = options.id;
if (!diaryId) { wx.showToast({ title: '参数错误', icon: 'none' }); return; }
this.setData({ diaryId: parseInt(diaryId), myUid: wx.getStorageSync('userId') || 0 });
this.setData({ diaryId: String(diaryId), myUid: wx.getStorageSync('userId') || 0 });
this.loadDiary();
this.loadComments();
},
@@ -90,7 +90,7 @@ Page(withDataCache({
var result = await api.diaryCommentList({diary_id:this.data.diaryId,page:page,page_size:20});
if (generation !== this._commentsGeneration) return;
var added = itemsOf(result).map(function(comment) {
return Object.assign({}, comment, {_time:String(comment.created_at || comment.create_time || '').slice(0,16).replace('T',' ')});
return Object.assign({}, comment, {avatar_url:getImageUrl(comment.avatar_url || comment.avatar || ''),_time:String(comment.created_at || comment.create_time || '').slice(0,16).replace('T',' ')});
});
this.setData({comments:page===1?added:mergeUnique(this.data.comments,added),commentPage:page,commentsTotal:totalOf(result),hasMoreComments:!Array.isArray(result)&&(result.has_more != null?!!result.has_more:page*20<totalOf(result))});
} catch(e) {
@@ -186,6 +186,7 @@ Page(withDataCache({
this._commentsFirst = false;
this.setData({commentsLoadingMore:false});
var newComment = res.comment || {};
newComment.avatar_url = getImageUrl(newComment.avatar_url || newComment.avatar || "");
if (newComment.created_at) {
newComment._time = newComment.created_at.substring(0, 16).replace('T', ' ');
} else if (newComment.create_time) {
+13 -38
View File
@@ -1,3 +1,4 @@
const { uploadReference } = require('../../utils/file-media');
const { getLayout } = require('../../utils/layout');
const { api, API_BASE, getImageUrl, uploadFileCompat } = require('../../utils/request');
const { guardStorageSpace } = require('../../utils/storage-guard');
@@ -24,7 +25,7 @@ Page({
wx.showToast({ title: '参数错误', icon: 'none' });
return;
}
this.setData({ diaryId: parseInt(id) });
this.setData({ diaryId: String(id) });
this.loadDiary();
},
@@ -50,7 +51,7 @@ Page({
'form.lng': data.lng || 0,
'form.location': data.location || '',
tempImages: tempImages,
existingImages: imgs,
existingImages: Array.isArray(data.image_file_ids) && data.image_file_ids.length === imgs.length ? data.image_file_ids : imgs,
contentLength: content.length
});
} catch (e) {
@@ -110,10 +111,10 @@ Page({
var existing = this.data.existingImages || [];
var temps = this.data.tempImages || [];
// 判断哪些是新上传的图片(tempFilePaths 以 http 开头的是已有图片,否则是新选的)
// 已有图片通过原始引用识别;开发工具的本地图片也可能以 http 开头。
for (var i = 0; i < temps.length; i++) {
var p = temps[i];
if (p.indexOf('http') === 0) {
if (existing[i] && getImageUrl(existing[i]) === p) {
// 已有图片,用原始路径
uploadPaths.push(existing[i] || '');
} else {
@@ -149,42 +150,16 @@ Page({
},
_uploadImage: async function(filePath) {
try {
var data = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'diaries' }
});
var path = data && (data.path || data.url || '');
if (!path) {
// 内容安全检测失败:抛出错误提示用户
if (data && data.error) {
throw data;
}
try {
var retryData = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'diaries' }
});
path = retryData && (retryData.path || retryData.url || '');
} catch(retryErr) {
console.error('图片上传重试失败', retryErr);
}
}
return path;
} catch(e) {
// 内容安全违规错误:直接抛出,中断发布并提示用户
if (e && e.error) {
throw e;
}
console.error('图片上传失败', e);
return '';
}
var data = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'diaries' }
});
return uploadReference(data);
},
goBack: function() {
wx.navigateBack();
}
+4 -3
View File
@@ -1,3 +1,4 @@
const { uploadReference } = require('../../utils/file-media');
const { itemsOf, totalOf } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
@@ -359,7 +360,7 @@ Page(withDataCache({
hasPartner: true,
partnerName: partner.nickname || partner.username || 'TA',
partnerDisplayId: partner.user_id || '',
partnerId: Number(partner.id),
partnerId: String(partner.id),
partnerDetail: details.join(' · ') || 'TA的信息',
partnerAvatarUrl: getImageUrl(partner.avatar),
partnerInitial: (partner.nickname || partner.username || '?')[0],
@@ -511,7 +512,7 @@ Page(withDataCache({
const data = await api.uploadAvatar(filePath);
self.setData({
editAvatarUrl: getImageUrl(data.path),
editAvatarPath: data.path
editAvatarPath: uploadReference(data)
});
wx.showToast({ title: '头像上传成功 ✨', icon: 'success' });
} catch (e) {
@@ -663,7 +664,7 @@ Page(withDataCache({
self.setData({partnerAvatarUploading:true});
try {
const uploaded = await api.uploadPartnerAvatar(res.tempFilePaths[0],partnerId);
self.setData({editPartnerAvatarUrl:getImageUrl(uploaded.path),editPartnerAvatarPath:uploaded.path});
self.setData({editPartnerAvatarUrl:getImageUrl(uploaded.path),editPartnerAvatarPath:uploadReference(uploaded)});
} catch(error) { wx.showToast({title:error.error || '头像上传失败',icon:'none'}); }
finally { self.setData({partnerAvatarUploading:false}); }
}});
+16 -87
View File
@@ -1,3 +1,4 @@
const { uploadReference } = require('../../utils/file-media');
const { getLayout } = require('../../utils/layout');
// pages/publish/publish.js - 统一发布页(增强版编辑器)
const { api, API_BASE, uploadFileCompat } = require('../../utils/request');
@@ -50,7 +51,7 @@ Page({
onLoad: function(options) {
this.setData(getLayout());
var type = options.type || 'timeline';
var editId = options.edit ? parseInt(options.edit) : 0;
var editId = options.edit ? String(options.edit) : "";
var cfg = TYPE_CONFIG[type] || TYPE_CONFIG['timeline'];
var titleText = editId ? '编辑事件' : cfg.title;
var btnText = editId ? '保存修改' : cfg.btn;
@@ -173,7 +174,7 @@ Page({
repeat_unit: item.repeat_unit || ''
};
// 加载背景图
var bgPath = item.bg_image || '';
var bgPath = item.bg_image_file_id || item.bg_image || '';
var bgUrl = bgPath ? require('../../utils/request').getImageUrl(bgPath) : '';
var repeatOptionIdx = 0;
if (form.repeat_interval > 0 && form.repeat_unit) {
@@ -384,9 +385,8 @@ Page({
name: 'file',
formData: { sub_dir: 'timeline_bg' }
});
bgImagePath = uploadRes && (uploadRes.path || uploadRes.url || '');
} catch(e) {}
wx.hideLoading();
bgImagePath = uploadReference(uploadRes);
} finally { wx.hideLoading(); }
}
var result;
if (editId) {
@@ -451,93 +451,22 @@ Page({
},
_uploadAlbumCover: async function(filePath) {
try {
var data = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'albums' }
});
// 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'}
var path = data && (data.path || data.url || '');
if (!path) {
// 内容安全检测失败:抛出错误提示用户
if (data && data.error) {
throw data;
}
console.error('封面上传返回无路径', JSON.stringify(data), '错误码:', data && data.code);
// 尝试重新上传一次
try {
var retryData = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'albums' }
});
var retryPath = retryData && (retryData.path || retryData.url || '');
if (retryPath) {
console.log('封面上传重试成功');
return retryPath;
}
} catch(retryErr) {
console.error('封面上传重试也失败', retryErr, '错误码:', retryErr && retryErr.code);
}
}
return path;
} catch(e) {
// 内容安全违规错误:直接抛出,中断发布并提示用户
if (e && e.error) {
throw e;
}
console.error('封面上传失败', e, '错误码:', e && e.code);
return '';
}
return this._uploadMedia(filePath, 'albums');
},
_uploadDiaryImage: async function(filePath) {
try {
var data = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'diaries' }
});
// 兼容不同响应格式:{path:'...'} 或 {success:true, path:'...'} 或 {url:'...'}
var path = data && (data.path || data.url || '');
if (!path) {
// 内容安全检测失败:抛出错误提示用户
if (data && data.error) {
throw data;
}
console.error('日记图片上传返回无路径', JSON.stringify(data), '错误码:', data && data.code);
// 尝试重新上传一次
try {
var retryData = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: 'diaries' }
});
var retryPath = retryData && (retryData.path || retryData.url || '');
if (retryPath) {
console.log('日记图片重试上传成功');
return retryPath;
}
} catch(retryErr) {
console.error('日记图片重试上传也失败', retryErr, '错误码:', retryErr && retryErr.code);
}
}
return path;
} catch(e) {
// 内容安全违规错误:直接抛出,中断发布并提示用户
if (e && e.error) {
throw e;
}
console.error('日记图片上传失败', e, '错误码:', e && e.code);
return '';
}
return this._uploadMedia(filePath, 'diaries');
},
_uploadMedia: async function(filePath, subDir) {
var data = await uploadFileCompat({
url: API_BASE + '/upload',
filePath: filePath,
name: 'file',
formData: { sub_dir: subDir }
});
return uploadReference(data);
},
// ===== 背景图 =====
chooseBgImage: function() {
var self = this;
@@ -74,8 +74,8 @@ Page(withDataCache({
this.setData(getLayout());
var id = options.id;
if (id) {
this.setData({ detailId: parseInt(id) });
this.loadDetail(parseInt(id));
this.setData({ detailId: String(id) });
this.loadDetail(String(id));
}
var self = this;
var swipe = createSwipeBack(function() { wx.navigateBack(); });
@@ -108,7 +108,7 @@ Page(withDataCache({
try {
var item = await api.timelineDetail(id);
var myUid = wx.getStorageSync('userId');
var cid = parseInt(item.created_by) || 0;
var cid = String(item.created_by) || 0;
// 获取情侣信息
try {
+1 -1
View File
@@ -101,7 +101,7 @@ Page(withDataCache(withListPagination({
var today = new Date(); today.setHours(0,0,0,0);
var items = itemsOf(list).map(function(item, idx) {
var cid = parseInt(item.created_by) || 0;
var cid = String(item.created_by) || 0;
item.creator_name = nameMap[cid] || (cid == myUid ? '我' : 'TA');
item.creator_avatar = avatarMap[cid] || '';
item.creator_initial = (item.creator_name || '?')[0].toUpperCase();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compileType": "miniprogram",
"appid": "wxdc40d0c51cf8fe1e",
"appid": "wxb463820bf36dd5d6",
"projectname": "couple-space",
"description": "情侣空间小程序",
"setting": {
+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()=>{
+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),