feat: add WeChat Pay, Redis logging, storage tiers and personal albums
Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
This commit is contained in:
@@ -46,3 +46,26 @@ test('blank edit address uses current user location; a supplied address wins',as
|
||||
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' '));await p.savePhotoInfo();assert.equal(e.calls[0].location,'当前用户地址');assert.equal(e.calls[0].lat,25);assert.equal(p.data.batches[0].photos[0].location,'当前用户地址');
|
||||
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' 手填地址 '));await p.savePhotoInfo();assert.equal(e.calls[1].location,'手填地址');assert.equal(e.calls[1].lat,0);
|
||||
});
|
||||
|
||||
function uploadSetup(auth) {
|
||||
let definition;const calls=[],modals=[],toasts=[];
|
||||
const wx={getStorageSync:()=>'',showToast:v=>toasts.push(v),showModal:v=>modals.push(v),showLoading(){},hideLoading(){},switchTab:v=>calls.push(['switchTab',v]),redirectTo:v=>calls.push(['redirectTo',v]),chooseImage:()=>calls.push(['picker'])};
|
||||
const api={checkAuth:async()=>auth(),storageInfo:async()=>({is_unlimited:true}),albumList:async()=>({items:[]}),albumAdd:async()=>{calls.push(['albumAdd']);return {id:'c224bc8c-0848-442d-abf6-350fab34d669'};},photoAdd:async data=>{calls.push(['photoAdd',data]);return {success:true};}};
|
||||
const load=createLoader({wx,console,Date,Promise,Page:p=>definition=p},{'../../utils/request':{api,getImageUrl:v=>v,API_BASE:'https://we.sundynix.cn/api',uploadFileCompat:async()=>{calls.push(['upload']);return {file_id:'6487966a-28ac-41d1-97b6-26f279b11fa9'};}},'../../utils/user-location':{currentUserLocation:async()=>({location:'',lat:0,lng:0})}});
|
||||
load(path.join(__dirname,'../pages/album/album.js'));
|
||||
const page={...definition,data:{...definition.data,albumId:'c224bc8c-0848-442d-abf6-350fab34d669',uploadFilePaths:['photo.jpg'],photoFormList:[{}]},setData(v){Object.assign(this.data,v);},loadData(){calls.push(['reload']);}};
|
||||
return {page,calls,modals,toasts};
|
||||
}
|
||||
test('unbound users upload and save photos in their own UUID album',async()=>{
|
||||
const e=uploadSetup(()=>({logged_in:true,user:{couple_id:null}}));await e.page.uploadAll();
|
||||
assert.equal(e.calls[0][0],'upload');assert.equal(e.calls[1][0],'photoAdd');assert.equal(e.calls[1][1].path,'6487966a-28ac-41d1-97b6-26f279b11fa9');assert.equal(e.calls[2][0],'reload');assert.equal(e.page.data.showUploadModal,false);assert.equal(e.modals.length,0);
|
||||
});
|
||||
test('bound users keep uploading to the existing shared album',async()=>{
|
||||
const e=uploadSetup(()=>({logged_in:true,user:{couple_id:'bound'}}));await e.page.uploadAll();
|
||||
assert.equal(e.calls[1][0],'photoAdd');assert.equal(e.calls[1][1].album_id,'c224bc8c-0848-442d-abf6-350fab34d669');
|
||||
});
|
||||
|
||||
test('stale demo album is replaced with a real personal album before transferring photos',async()=>{
|
||||
const e=uploadSetup(()=>({logged_in:true,user:{couple_id:null}}));e.page.data.albumId=-1;await e.page.uploadAll();
|
||||
assert.equal(e.calls[0][0],'albumAdd');assert.equal(e.calls[1][0],'upload');assert.equal(e.calls[2][0],'photoAdd');assert.equal(e.calls[2][1].album_id,'c224bc8c-0848-442d-abf6-350fab34d669');
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ test('development requests and file previews use local backend, trial and releas
|
||||
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(API_BASE, version === 'develop' ? 'http://127.0.0.1:8080/api' : 'https://we.sundynix.cn/api');
|
||||
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),API_BASE+'/files/6083e119-6b26-441b-8b70-44d224366238');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ function client(extra = {}) {
|
||||
}
|
||||
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.diaryUpdate({id:'diary',images:['https://we.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});
|
||||
@@ -32,7 +32,7 @@ test('duplicate and ID-only uploads keep stable display URLs and file metadata',
|
||||
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.equal(c.getImageUrl(result.path),'https://we.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 () => {
|
||||
@@ -55,5 +55,5 @@ test('media display keeps local assets and temporary paths without a backend dir
|
||||
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);
|
||||
assert.equal(c.getImageUrl(id), 'https://we.sundynix.cn/api/files/' + id);
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ test('profile editing hides tabs until close and reserves a fixed save area outs
|
||||
});
|
||||
test('photo memory detail uses light system controls and restores them on exit',()=>{
|
||||
const createLoader=require('./load-module.cjs');let definition;const colors=[];let back=0;
|
||||
const load=createLoader({Page:value=>definition=value,wx:{setNavigationBarColor:value=>colors.push(value),navigateBack:()=>back++},console,Date},{'../../utils/request':{api:{},getImageUrl:value=>'https://past.sundynix.cn/backend/'+value}});
|
||||
const load=createLoader({Page:value=>definition=value,wx:{setNavigationBarColor:value=>colors.push(value),navigateBack:()=>back++},console,Date},{'../../utils/request':{api:{},getImageUrl:value=>'https://we.sundynix.cn/backend/'+value}});
|
||||
load(path.join(root,'pages/timeline-detail/timeline-detail.js'));
|
||||
const page={...definition,data:{...definition.data,item:{bg_image:'uploads/memory.jpg'}},setData(value){Object.assign(this.data,value);}};
|
||||
page.loadBgImage();assert.equal(colors.at(-1).frontColor,'#ffffff');assert.ok(page.data.bgImageUrl.endsWith('memory.jpg'));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const createLoader = require('./load-module.cjs');
|
||||
function setup(statuses) {
|
||||
let definition, cashier, queries = 0, refreshes = 0;
|
||||
const toasts = [];
|
||||
const load = createLoader({Page(p) { definition = p; }, console, Promise, Date,
|
||||
setTimeout(resolve) { resolve(); },
|
||||
wx: { requestPayment(o) { cashier = o; }, showToast(o) { toasts.push(o); } }
|
||||
}, {'../../utils/request': {api: {async storageOrderStatus(number) {
|
||||
assert.equal(number, 'P12345678');
|
||||
const result = statuses[Math.min(queries++, statuses.length - 1)];
|
||||
if (result instanceof Error) throw result;
|
||||
return result;
|
||||
}}}});
|
||||
load(path.join(__dirname,'../pages/storage/storage.js'));
|
||||
const page = {...definition,data:{paying:true},setData(data){Object.assign(this.data,data);},async loadInfo(){refreshes++;}};
|
||||
const order = {out_trade_no:'P12345678',payment:{time_stamp:'123',nonce_str:'nonce',package:'prepay_id=123',sign_type:'RSA',pay_sign:'signed'}};
|
||||
page.callWechatPay(order);
|
||||
return {page,toasts,get cashier(){return cashier;},get queries(){return queries;},get refreshes(){return refreshes;}};
|
||||
}
|
||||
test('JSAPI uses signed fields and only announces membership after server confirmation', async () => {
|
||||
const e = setup([{status:'pending'},{status:'success'}]);
|
||||
assert.equal(e.cashier.timeStamp,'123');
|
||||
assert.equal(e.cashier.nonceStr,'nonce');
|
||||
assert.equal(e.cashier.package,'prepay_id=123');
|
||||
assert.equal(e.cashier.signType,'RSA');
|
||||
assert.equal(e.cashier.paySign,'signed');
|
||||
assert.equal(e.toasts.length,0);
|
||||
await e.cashier.success();
|
||||
assert.equal(e.queries,2);
|
||||
assert.equal(e.refreshes,1);
|
||||
assert.equal(e.toasts[0].title,'支付成功,会员已开通');
|
||||
assert.equal(e.page.data.paying,false);
|
||||
});
|
||||
test('cancelled cashier checks for completed payment once, without granting pending orders',async()=>{
|
||||
const e=setup([{status:'pending'}]);
|
||||
await e.cashier.fail({errMsg:'requestPayment:fail cancel'});
|
||||
assert.equal(e.queries,1);assert.equal(e.refreshes,0);
|
||||
assert.equal(e.toasts[0].title,'已取消支付');assert.equal(e.page.data.paying,false);
|
||||
const paid=setup([{status:'success'}]);
|
||||
await paid.cashier.fail({errMsg:'requestPayment:fail'});
|
||||
assert.equal(paid.refreshes,1);assert.equal(paid.toasts[0].title,'支付成功,会员已开通');
|
||||
});
|
||||
test('unknown payment results and network errors never claim success',async()=>{
|
||||
for(const status of [{status:'pending'},Error('offline')]){
|
||||
const e=setup([status]);await e.cashier.success();
|
||||
assert.equal(e.refreshes,0);assert.equal(e.toasts[0].icon,'none');assert.equal(e.page.data.paying,false);
|
||||
}
|
||||
});
|
||||
@@ -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('/api/files/6083e119-6b26-441b-8b70-44d224366238'),'https://past.sundynix.cn/api/files/6083e119-6b26-441b-8b70-44d224366238');
|
||||
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),'https://we.sundynix.cn/api/files/6083e119-6b26-441b-8b70-44d224366238');
|
||||
});
|
||||
|
||||
test('settings and profile consent retain couple cache and lead into cooling',async()=>{
|
||||
|
||||
@@ -12,16 +12,16 @@ test('GET business parameters and POST bodies use clean paths with auth', async
|
||||
const response = await api.diaryList({page:2,page_size:20});
|
||||
assert.equal(response.page_size,20);
|
||||
await api.periodVisibleSet({period_visible:0});
|
||||
assert.equal(calls[0].url,'https://past.sundynix.cn/api/diaryList?page=2&pageSize=20');
|
||||
assert.equal(calls[0].url,'https://we.sundynix.cn/api/diaryList?page=2&pageSize=20');
|
||||
assert.equal(calls[0].header.Authorization,'Bearer test-token');
|
||||
assert.equal(calls[1].url,'https://past.sundynix.cn/api/periodVisibleSet');
|
||||
assert.equal(calls[1].url,'https://we.sundynix.cn/api/periodVisibleSet');
|
||||
assert.equal(calls[1].data.periodVisible,0);
|
||||
});
|
||||
test('multipart upload retains action and token when Android omits headers',async()=>{
|
||||
let upload;
|
||||
const {uploadFileCompat}=load('utils/request.js',{getStorageSync:()=> 'test-token',compressImage(o){o.fail();},uploadFile(o){upload=o;o.success({statusCode:200,data:'{"success":true,"path":"uploads/test.jpg"}'});} });
|
||||
await uploadFileCompat({url:'https://past.sundynix.cn/api/uploadPartnerAvatar',filePath:'test.jpg',name:'file',formData:{partner_id:87}});
|
||||
assert.equal(upload.url,'https://past.sundynix.cn/api/uploadPartnerAvatar?token=test-token');
|
||||
await uploadFileCompat({url:'https://we.sundynix.cn/api/uploadPartnerAvatar',filePath:'test.jpg',name:'file',formData:{partner_id:87}});
|
||||
assert.equal(upload.url,'https://we.sundynix.cn/api/uploadPartnerAvatar?token=test-token');
|
||||
assert.equal(upload.formData.action,'uploadPartnerAvatar');
|
||||
assert.equal(upload.formData.token,'test-token');
|
||||
assert.equal(upload.formData.partnerId,87);
|
||||
|
||||
@@ -5,7 +5,7 @@ const createLoader=require('./load-module.cjs');
|
||||
function setup(action,info) {
|
||||
let definition;const calls=[],toasts=[],events=[];
|
||||
const api={coupleInfo:async()=>({partner1:{id:1,nickname:'本人',avatar:'uploads/me.jpg'},partner2:{id:2,nickname:'伴侣',avatar:'uploads/partner.jpg'}}),smokingAction:async payload=>{calls.push(payload);return action(payload);},smokingInfo:info||(async()=>({stock:5,today_count:0,logs:[],daily:[],available:true,read_only:false}))};
|
||||
const load=createLoader({Component:c=>definition=c,Date,Math,Promise,wx:{getStorageSync:()=>1,showToast:v=>toasts.push(v),showModal:o=>o.success({confirm:true}),navigateTo(){}}},{'../../utils/request':{api,getImageUrl:value=>value?'https://past.sundynix.cn/backend/'+value:''}});
|
||||
const load=createLoader({Component:c=>definition=c,Date,Math,Promise,wx:{getStorageSync:()=>1,showToast:v=>toasts.push(v),showModal:o=>o.success({confirm:true}),navigateTo(){}}},{'../../utils/request':{api,getImageUrl:value=>value?'https://we.sundynix.cn/backend/'+value:''}});
|
||||
load(path.join(__dirname,'../components/smoking-card/smoking-card.js'));
|
||||
const component={...definition.methods,properties:{detailed:false},data:JSON.parse(JSON.stringify(definition.data)),setData(values){Object.assign(this.data,values);},triggerEvent:name=>events.push(name)};
|
||||
component.applyInfo({stock:5,today_count:0,logs:[],daily:[],available:true,read_only:false});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const path=require('node:path');
|
||||
const loader=require('./load-module.cjs');
|
||||
test('finite paid space uses its quota, zero is respected, unlimited is explicit',()=>{
|
||||
const load=loader({wx:{},console},{'./request':{api:{}}});
|
||||
const {quotaExceeded}=load(path.join(__dirname,'../utils/storage-guard.js'));
|
||||
assert.equal(quotaExceeded({isVip:true,isUnlimited:false,quotaBytes:1024,usedBytes:1000},25),true);
|
||||
assert.equal(quotaExceeded({is_vip:true,is_unlimited:false,quota_bytes:1024,used_bytes:1000},24),false);
|
||||
assert.equal(quotaExceeded({isVip:true,isUnlimited:true,quotaBytes:0,usedBytes:9999},100),false);
|
||||
assert.equal(quotaExceeded({quotaBytes:0,freeBytes:200,usedBytes:1},0),true);
|
||||
});
|
||||
test('storage page renders 1-cent plans and custom month/capacity values',async()=>{
|
||||
let definition;
|
||||
const info={freeBytes:200*1024*1024,quotaBytes:1024*1024*1024,usedBytes:512*1024*1024,isVip:true,isUnlimited:false,vipExpireAt:'2026-12-02',plans:[{key:'custom',name:'测试套餐',priceCents:1,quotaMb:1024,months:3}]};
|
||||
const load=loader({Page(p){definition=p},wx:{},console},{'../../utils/request':{api:{storageInfo:async()=>info}}});
|
||||
load(path.join(__dirname,'../pages/storage/storage.js'));
|
||||
const page={...definition,data:{...definition.data},setData(d){Object.assign(this.data,d)}};
|
||||
await page.loadInfo();
|
||||
assert.equal(page.data.plans[0].priceText,'0.01');
|
||||
assert.equal(page.data.plans[0].unit,'3个月');
|
||||
assert.match(page.data.plans[0].description,/1.00 GB/);
|
||||
assert.equal(page.data.percent,50);
|
||||
assert.equal(page.data.isVip,true);
|
||||
assert.equal(page.data.isUnlimited,false);
|
||||
assert.equal(page.data.quotaText,'1.00 GB');
|
||||
});
|
||||
Reference in New Issue
Block a user