Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-be
Commits
441be079
Commit
441be079
authored
Aug 18, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(receipts): store transaction receipts in private R2
parent
efbc2710
Changes
6
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
115 additions
and
27 deletions
+115
-27
memory.md
.agents/memory.md
+3
-2
.env.example
.env.example
+1
-0
api.md
docs/api.md
+1
-1
r2-direct-upload.md
docs/r2-direct-upload.md
+6
-1
receipt-file.service.ts
src/modules/transactions/receipt-file.service.ts
+94
-10
transaction.controller.ts
src/modules/transactions/transaction.controller.ts
+10
-13
No files found.
.agents/memory.md
View file @
441be079
...
@@ -28,8 +28,9 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
...
@@ -28,8 +28,9 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
khác nhau, thuộc cùng người dùng và ví nguồn đủ số dư; create/delete cập nhật cả hai số dư cùng bản ghi
khác nhau, thuộc cùng người dùng và ví nguồn đủ số dư; create/delete cập nhật cả hai số dư cùng bản ghi
Transfer trong Prisma transaction mức Serializable. Delete là thao tác hiệu chỉnh nên vẫn hoàn tác được
Transfer trong Prisma transaction mức Serializable. Delete là thao tác hiệu chỉnh nên vẫn hoàn tác được
vào ví đã archive; mọi thay đổi Transfer đều xóa cache báo cáo tài chính của người dùng.
vào ví đã archive; mọi thay đổi Transfer đều xóa cache báo cáo tài chính của người dùng.
-
Hóa đơn Transaction được lưu cục bộ dưới
`storage/receipts`
, chỉ đọc qua API có auth;
-
Hóa đơn Transaction mới được lưu riêng tư trên Cloudflare R2 dưới prefix ownership
hỗ trợ JPEG, PNG, WebP, PDF và giới hạn mặc định 5 MB.
`receipts/<userId>/`
, chỉ đọc qua API có auth; hỗ trợ JPEG, PNG, WebP, PDF và giới hạn mặc định
5 MB. Backend vẫn đọc/xóa tương thích key local cũ dưới
`storage/receipts`
.
-
Upload file mới dùng mô hình browser tải trực tiếp lên Cloudflare R2 qua presigned PUT URL
-
Upload file mới dùng mô hình browser tải trực tiếp lên Cloudflare R2 qua presigned PUT URL
do backend cấp. Hiện chỉ bật purpose
`avatar`
(JPEG/PNG/WebP, mặc định 5 MB), object key do
do backend cấp. Hiện chỉ bật purpose
`avatar`
(JPEG/PNG/WebP, mặc định 5 MB), object key do
server sinh theo user và public URL chỉ được lưu vào profile sau khi PUT thành công. Khi mở rộng,
server sinh theo user và public URL chỉ được lưu vào profile sau khi PUT thành công. Khi mở rộng,
...
...
.env.example
View file @
441be079
...
@@ -33,6 +33,7 @@ TRUST_PROXY=false
...
@@ -33,6 +33,7 @@ TRUST_PROXY=false
ALLOWED_ORIGINS=http://localhost:2999,http://localhost:3000,http://localhost:5173
ALLOWED_ORIGINS=http://localhost:2999,http://localhost:3000,http://localhost:5173
# Legacy local receipt directory (read-only compatibility for files uploaded before R2)
RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_MAX_FILE_SIZE_MB=5
RECEIPT_MAX_FILE_SIZE_MB=5
...
...
docs/api.md
View file @
441be079
...
@@ -96,7 +96,7 @@ Tất cả các endpoint bên dưới có tiền tố (prefix) mặc định: `/
...
@@ -96,7 +96,7 @@ Tất cả các endpoint bên dưới có tiền tố (prefix) mặc định: `/
*
`POST /transactions`
[
Bearer
]
- Tạo giao dịch thu/chi (tự động cộng/trừ số dư ví liên quan dưới database transaction Serializable).
*
`POST /transactions`
[
Bearer
]
- Tạo giao dịch thu/chi (tự động cộng/trừ số dư ví liên quan dưới database transaction Serializable).
*
`PUT /transactions/:id`
[
Bearer
]
- Cập nhật giao dịch (tự động tính toán lại và hoàn tác/cập nhật số dư ví cũ và mới tương ứng).
*
`PUT /transactions/:id`
[
Bearer
]
- Cập nhật giao dịch (tự động tính toán lại và hoàn tác/cập nhật số dư ví cũ và mới tương ứng).
*
`DELETE /transactions/:id`
[
Bearer
]
- Xóa giao dịch (tự động hoàn trả số dư ví về trạng thái trước giao dịch).
*
`DELETE /transactions/:id`
[
Bearer
]
- Xóa giao dịch (tự động hoàn trả số dư ví về trạng thái trước giao dịch).
*
`PUT /transactions/:id/receipt`
[
Bearer
]
- Tải
lên ảnh hóa đơn
(
`receipt`
) dạng multipart-form (hỗ trợ JPEG, PNG, WebP, PDF tối đa 5MB).
*
`PUT /transactions/:id/receipt`
[
Bearer
]
- Tải
ảnh hóa đơn riêng tư lên Cloudflare R2
(
`receipt`
) dạng multipart-form (hỗ trợ JPEG, PNG, WebP, PDF tối đa 5MB).
*
`GET /transactions/:id/receipt`
[
Bearer
]
- Xem/Tải xuống tệp hóa đơn đã upload bảo mật.
*
`GET /transactions/:id/receipt`
[
Bearer
]
- Xem/Tải xuống tệp hóa đơn đã upload bảo mật.
### 3.6 Ngân sách chi tiêu (Budgets)
### 3.6 Ngân sách chi tiêu (Budgets)
...
...
docs/r2-direct-upload.md
View file @
441be079
...
@@ -2,7 +2,7 @@
...
@@ -2,7 +2,7 @@
## Phạm vi hiện tại
## Phạm vi hiện tại
Luồng presigned PUT hiện
chỉ phục vụ
**avatar**
. Frontend không gửi file
qua backend:
Luồng presigned PUT hiện
phục vụ
**avatar**
. Frontend không gửi file avatar
qua backend:
1.
Browser gọi
`POST /api/v1/uploads/presign`
bằng phiên đăng nhập hiện tại.
1.
Browser gọi
`POST /api/v1/uploads/presign`
bằng phiên đăng nhập hiện tại.
2.
Backend kiểm tra purpose, MIME type và kích thước khai báo, tự sinh object key rồi ký URL ngắn hạn.
2.
Backend kiểm tra purpose, MIME type và kích thước khai báo, tự sinh object key rồi ký URL ngắn hạn.
...
@@ -11,6 +11,11 @@ Luồng presigned PUT hiện chỉ phục vụ **avatar**. Frontend không gửi
...
@@ -11,6 +11,11 @@ Luồng presigned PUT hiện chỉ phục vụ **avatar**. Frontend không gửi
R2 credentials chỉ tồn tại ở backend. Presigned URL là bearer credential ngắn hạn, không log hoặc lưu URL này.
R2 credentials chỉ tồn tại ở backend. Presigned URL là bearer credential ngắn hạn, không log hoặc lưu URL này.
Hóa đơn giao dịch cũng được lưu trong R2 dưới prefix riêng tư
`receipts/<userId>/`
, nhưng binary
đi qua endpoint giao dịch có xác thực để backend kiểm tra chữ ký file và ownership. Hóa đơn không
dùng public URL;
`GET /transactions/:id/receipt`
đọc object sau khi kiểm tra giao dịch thuộc người dùng.
Các key local cũ vẫn được đọc tương thích từ
`RECEIPT_UPLOAD_DIR`
.
## Cấu hình backend
## Cấu hình backend
Các biến cần thiết được liệt kê trong
`.env.example`
:
Các biến cần thiết được liệt kê trong
`.env.example`
:
...
...
src/modules/transactions/receipt-file.service.ts
View file @
441be079
import
{
randomUUID
}
from
'crypto'
;
import
{
randomUUID
}
from
'crypto'
;
import
{
access
,
mkdir
,
unlink
,
writeFile
}
from
'fs/promises'
;
import
{
DeleteObjectCommand
,
GetObjectCommand
,
PutObjectCommand
,
S3Client
,
}
from
'@aws-sdk/client-s3'
;
import
{
access
,
unlink
}
from
'fs/promises'
;
import
path
from
'path'
;
import
path
from
'path'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
import
{
ERROR_CODE
}
from
'../../common/errors/error-code'
;
import
{
ERROR_CODE
}
from
'../../common/errors/error-code'
;
...
@@ -21,8 +27,10 @@ const mimeTypeByExtension: Record<string, string> = {
...
@@ -21,8 +27,10 @@ const mimeTypeByExtension: Record<string, string> = {
export
interface
StoredReceipt
{
export
interface
StoredReceipt
{
key
:
string
;
key
:
string
;
absolutePath
:
string
;
buffer
?:
Buffer
;
absolutePath
?:
string
;
mimeType
:
string
;
mimeType
:
string
;
extension
:
string
;
}
}
export
class
ReceiptFileService
{
export
class
ReceiptFileService
{
...
@@ -38,18 +46,52 @@ export class ReceiptFileService {
...
@@ -38,18 +46,52 @@ export class ReceiptFileService {
);
);
}
}
const
fileName
=
`
${
randomUUID
()}${
extension
}
`
;
const
key
=
`receipts/
${
userId
}
/
${
randomUUID
()}${
extension
}
`
;
const
key
=
`
${
userId
}
/
${
fileName
}
`
;
const
client
=
this
.
createR2Client
();
const
absolutePath
=
this
.
resolveSafePath
(
key
);
await
client
.
send
(
new
PutObjectCommand
({
const
userDir
=
path
.
dirname
(
absolutePath
);
Bucket
:
envConfig
.
r2
.
bucketName
,
await
mkdir
(
userDir
,
{
recursive
:
true
});
Key
:
key
,
Body
:
file
.
buffer
,
await
writeFile
(
absolutePath
,
file
.
buffer
,
{
flag
:
'wx'
});
ContentType
:
file
.
mimetype
,
ContentLength
:
file
.
size
,
}));
return
key
;
return
key
;
}
}
async
resolve
(
key
:
string
):
Promise
<
StoredReceipt
>
{
async
resolve
(
key
:
string
):
Promise
<
StoredReceipt
>
{
if
(
this
.
isR2ReceiptKey
(
key
))
{
const
client
=
this
.
createR2Client
();
try
{
const
object
=
await
client
.
send
(
new
GetObjectCommand
({
Bucket
:
envConfig
.
r2
.
bucketName
,
Key
:
key
,
}));
if
(
!
object
.
Body
)
{
throw
new
Error
(
'R2 object body is empty'
);
}
const
extension
=
path
.
extname
(
key
).
toLowerCase
();
const
mimeType
=
object
.
ContentType
||
mimeTypeByExtension
[
extension
];
if
(
!
mimeType
||
!
mimeTypeByExtension
[
extension
])
{
throw
new
Error
(
'R2 object has an unsupported content type'
);
}
return
{
key
,
buffer
:
Buffer
.
from
(
await
object
.
Body
.
transformToByteArray
()),
mimeType
,
extension
,
};
}
catch
(
error
)
{
if
(
error
instanceof
AppError
)
{
throw
error
;
}
throw
new
AppError
(
'Receipt file not found'
,
404
,
ERROR_CODE
.
RECEIPT_NOT_FOUND
);
}
}
// Compatibility path for receipts created before R2 storage was enabled.
const
absolutePath
=
this
.
resolveSafePath
(
key
);
const
absolutePath
=
this
.
resolveSafePath
(
key
);
try
{
try
{
...
@@ -64,7 +106,7 @@ export class ReceiptFileService {
...
@@ -64,7 +106,7 @@ export class ReceiptFileService {
throw
new
AppError
(
'Receipt file not found'
,
404
,
ERROR_CODE
.
RECEIPT_NOT_FOUND
);
throw
new
AppError
(
'Receipt file not found'
,
404
,
ERROR_CODE
.
RECEIPT_NOT_FOUND
);
}
}
return
{
key
,
absolutePath
,
mimeType
};
return
{
key
,
absolutePath
,
mimeType
,
extension
};
}
}
async
remove
(
key
:
string
|
null
):
Promise
<
void
>
{
async
remove
(
key
:
string
|
null
):
Promise
<
void
>
{
...
@@ -72,6 +114,19 @@ export class ReceiptFileService {
...
@@ -72,6 +114,19 @@ export class ReceiptFileService {
return
;
return
;
}
}
if
(
this
.
isR2ReceiptKey
(
key
))
{
try
{
const
client
=
this
.
createR2Client
();
await
client
.
send
(
new
DeleteObjectCommand
({
Bucket
:
envConfig
.
r2
.
bucketName
,
Key
:
key
,
}));
}
catch
{
// Object cleanup is best effort after the database state has been committed.
}
return
;
}
try
{
try
{
await
unlink
(
this
.
resolveSafePath
(
key
));
await
unlink
(
this
.
resolveSafePath
(
key
));
}
catch
{
}
catch
{
...
@@ -79,6 +134,35 @@ export class ReceiptFileService {
...
@@ -79,6 +134,35 @@ export class ReceiptFileService {
}
}
}
}
private
createR2Client
():
S3Client
{
const
config
=
envConfig
.
r2
;
if
(
!
config
.
accountId
||
!
config
.
bucketName
||
!
config
.
accessKeyId
||
!
config
.
secretAccessKey
)
{
throw
new
AppError
(
'Cloudflare R2 storage is not configured'
,
503
,
ERROR_CODE
.
STORAGE_NOT_CONFIGURED
,
);
}
return
new
S3Client
({
region
:
'auto'
,
endpoint
:
`https://
${
config
.
accountId
}
.r2.cloudflarestorage.com`
,
credentials
:
{
accessKeyId
:
config
.
accessKeyId
,
secretAccessKey
:
config
.
secretAccessKey
,
},
});
}
private
isR2ReceiptKey
(
key
:
string
):
boolean
{
return
/^receipts
\/[
0-9a-f-
]
+
\/[
0-9a-f-
]
+
\.(?:
jpg|png|webp|pdf
)
$/i
.
test
(
key
);
}
private
resolveSafePath
(
key
:
string
):
string
{
private
resolveSafePath
(
key
:
string
):
string
{
const
normalizedKey
=
key
.
replace
(
/
\\
/g
,
'/'
);
const
normalizedKey
=
key
.
replace
(
/
\\
/g
,
'/'
);
const
absolutePath
=
path
.
resolve
(
this
.
rootDir
,
normalizedKey
);
const
absolutePath
=
path
.
resolve
(
this
.
rootDir
,
normalizedKey
);
...
...
src/modules/transactions/transaction.controller.ts
View file @
441be079
import
path
from
'path
'
;
import
{
readFile
}
from
'fs/promises
'
;
import
{
NextFunction
,
Request
,
Response
}
from
'express'
;
import
{
NextFunction
,
Request
,
Response
}
from
'express'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
import
{
ERROR_CODE
}
from
'../../common/errors/error-code'
;
import
{
ERROR_CODE
}
from
'../../common/errors/error-code'
;
...
@@ -101,19 +101,16 @@ export class TransactionController {
...
@@ -101,19 +101,16 @@ export class TransactionController {
getReceipt
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
getReceipt
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
try
{
const
receipt
=
await
this
.
service
.
getReceipt
(
req
.
user
.
id
,
req
.
params
.
id
);
const
receipt
=
await
this
.
service
.
getReceipt
(
req
.
user
.
id
,
req
.
params
.
id
);
const
fileName
=
`receipt
${
path
.
extname
(
receipt
.
absolutePath
)}
`
;
const
content
=
receipt
.
buffer
??
await
readFile
(
receipt
.
absolutePath
!
);
res
.
sendFile
(
receipt
.
absolutePath
,
{
res
.
set
({
headers
:
{
'Content-Type'
:
receipt
.
mimeType
,
'Content-Type'
:
receipt
.
mimeType
,
'Content-Disposition'
:
`inline; filename="
${
fileName
}
"`
,
'Content-Disposition'
:
`inline; filename="receipt
${
receipt
.
extension
}
"`
,
'Content-Length'
:
content
.
length
.
toString
(),
'Cache-Control'
:
'private, max-age=3600'
,
'Cache-Control'
:
'private, max-age=3600'
,
},
},
(
error
)
=>
{
if
(
error
&&
!
res
.
headersSent
)
{
next
(
error
);
}
});
});
res
.
send
(
content
);
}
catch
(
error
)
{
}
catch
(
error
)
{
next
(
error
);
next
(
error
);
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment