refactor: streamline Redis caching logic across services

- Implemented a unified `getAndSet` method in RedisService to handle caching for single, list, and paginated responses.
- Removed redundant cache checks and writes in various services, simplifying the code and improving readability.
- Updated GoodsService, StockKeepingUnitsService, PartnerActivatedLicensesService, and others to utilize the new caching mechanism.
- Adjusted Prisma connection limit for better resource management.
This commit is contained in:
2026-05-21 17:27:37 +03:30
parent 1d47fb1a1d
commit 9aa12184a1
16 changed files with 689 additions and 1896 deletions
+158 -1
View File
@@ -1,17 +1,162 @@
# AGENT.md # GapCode Agent Instructions
# AGENTS.md
Operational guide for AI/coding agents working in `consumer_api`. Operational guide for AI/coding agents working in `consumer_api`.
---
# ⚡ RTK LOW-TOKEN EXECUTION POLICY (MANDATORY)
This repository uses **RTK (Rust Token Killer)**.
All shell interactions MUST prefer RTK wrappers to reduce token usage.
If an RTK equivalent exists, **never use the raw command**.
This repository can be large; uncontrolled shell output will quickly exhaust model context.
---
# ✅ COMMAND REWRITE RULES (STRICT)
Always rewrite commands using RTK:
git status → rtk git status
git diff → rtk git diff
git log → rtk git log
ls → rtk ls
tree → rtk ls
cat <file> → rtk read <file>
grep <pattern> → rtk grep <pattern>
rg <pattern> → rtk grep <pattern>
find → rtk find
Never run:
- raw git commands for inspection
- raw cat
- raw grep
- raw ls
- raw tree
---
# 📂 CODE NAVIGATION WORKFLOW (MANDATORY)
When working in this repository follow this order:
1️⃣ Discover structure
rtk ls
2️⃣ Search before opening files
rtk grep <symbol | class | function | DTO | service>
3️⃣ Read only the necessary files
rtk read <file>
4️⃣ For large files (>300 lines)
rtk read <file> -l aggressive
5️⃣ For quick understanding
rtk smart <file>
Never open many files blindly.
Never read entire modules without searching first.
---
# 🧠 DIFF INSPECTION RULES
For reviewing repository changes:
Use:
rtk git diff
For large diffs:
rtk git diff -l aggressive
Never run raw `git diff`.
---
# 📁 FORBIDDEN PATHS
Never inspect or read these directories unless explicitly required:
node_modules/
dist/
build/
coverage/
.prisma/
prisma/migrations/
These folders produce extremely large outputs and waste tokens.
---
# 📄 FILE READING POLICY
Preferred:
rtk read file.ts
Large file:
rtk read file.ts -l aggressive
Quick overview:
rtk smart file.ts
Never use `cat` for source code inspection.
---
# 🔎 SEARCH POLICY
Always search before opening files.
Use:
rtk grep <pattern>
Avoid raw recursive searches.
---
# 🎯 TOKEN SAFETY RULES
- Never scan the entire repository.
- Never dump full logs.
- Never output entire large files.
- Prefer targeted inspection.
Token preservation is mandatory for this project.
---
## Scope ## Scope
- Applies to the whole repository. - Applies to the whole repository.
- Stack: NestJS + Prisma + TypeScript. - Stack: NestJS + Prisma + TypeScript.
## Primary Goals ## Primary Goals
- Deliver minimal, safe, and focused changes. - Deliver minimal, safe, and focused changes.
- Preserve existing API behavior unless explicitly requested. - Preserve existing API behavior unless explicitly requested.
- Keep Prisma schema/data operations forward-safe. - Keep Prisma schema/data operations forward-safe.
## Project Conventions ## Project Conventions
- Keep module layering consistent: `controller -> service -> prisma/shared service`. - Keep module layering consistent: `controller -> service -> prisma/shared service`.
- Reuse shared services for cross-module business logic (for example, sales invoice create flow). - Reuse shared services for cross-module business logic (for example, sales invoice create flow).
- Keep DTO validation at API boundaries; avoid unchecked `any` in new code. - Keep DTO validation at API boundaries; avoid unchecked `any` in new code.
@@ -19,6 +164,7 @@ Operational guide for AI/coding agents working in `consumer_api`.
- Prefer explicit Prisma `select`/`include` to control payload shape. - Prefer explicit Prisma `select`/`include` to control payload shape.
## Sales Invoice / TSP Rules ## Sales Invoice / TSP Rules
- `originalSend`, `correctionSend`, and `revoke` flows should be consistent and auditable. - `originalSend`, `correctionSend`, and `revoke` flows should be consistent and auditable.
- For correction/revoke creation, prepare data from the related invoice when required. - For correction/revoke creation, prepare data from the related invoice when required.
- Persist attempt records with clear status transitions (`QUEUED` -> final status). - Persist attempt records with clear status transitions (`QUEUED` -> final status).
@@ -26,6 +172,7 @@ Operational guide for AI/coding agents working in `consumer_api`.
- Avoid changing fiscal/tax status semantics without explicit approval. - Avoid changing fiscal/tax status semantics without explicit approval.
## Prisma and Migration Safety ## Prisma and Migration Safety
- Treat committed migrations as immutable history. - Treat committed migrations as immutable history.
- Prefer forward migrations; avoid destructive resets unless explicitly requested. - Prefer forward migrations; avoid destructive resets unless explicitly requested.
- For data-affecting changes: - For data-affecting changes:
@@ -34,12 +181,14 @@ Operational guide for AI/coding agents working in `consumer_api`.
- keep logic idempotent where possible. - keep logic idempotent where possible.
## Editing Principles ## Editing Principles
- Do not modify unrelated files. - Do not modify unrelated files.
- Do not revert user changes unless asked. - Do not revert user changes unless asked.
- Keep functions cohesive; extract shared logic when duplication appears. - Keep functions cohesive; extract shared logic when duplication appears.
- Remove debug leftovers (`console.log`, dead code) before finishing unless explicitly needed. - Remove debug leftovers (`console.log`, dead code) before finishing unless explicitly needed.
## Validation Checklist (before handoff) ## Validation Checklist (before handoff)
1. Read target module/service/DTO end-to-end before edits. 1. Read target module/service/DTO end-to-end before edits.
2. Apply minimal patch with consistent naming. 2. Apply minimal patch with consistent naming.
3. Run typecheck: `pnpm -s tsc --noEmit`. 3. Run typecheck: `pnpm -s tsc --noEmit`.
@@ -47,12 +196,14 @@ Operational guide for AI/coding agents working in `consumer_api`.
5. Summarize changed files and behavior impact clearly. 5. Summarize changed files and behavior impact clearly.
## Useful Commands ## Useful Commands
- Typecheck: `pnpm -s tsc --noEmit` - Typecheck: `pnpm -s tsc --noEmit`
- Migration status: `pnpm prisma migrate status` - Migration status: `pnpm prisma migrate status`
- Create migration: `pnpm prisma migrate dev --name <name>` - Create migration: `pnpm prisma migrate dev --name <name>`
- Deploy migrations: `pnpm prisma migrate deploy` - Deploy migrations: `pnpm prisma migrate deploy`
## Communication Style ## Communication Style
- Be concise and implementation-focused. - Be concise and implementation-focused.
- Call out assumptions/risk before risky steps. - Call out assumptions/risk before risky steps.
- Provide practical next actions after task completion. - Provide practical next actions after task completion.
@@ -60,6 +211,7 @@ Operational guide for AI/coding agents working in `consumer_api`.
## Do / Don't (Repo-Specific) ## Do / Don't (Repo-Specific)
### Do ### Do
- Do derive correction/revoke invoice creation data from `relatedInvoice` when the flow requires historical consistency. - Do derive correction/revoke invoice creation data from `relatedInvoice` when the flow requires historical consistency.
- Do keep TSP attempt lifecycle explicit (`QUEUED`, then update with provider result and timestamps). - Do keep TSP attempt lifecycle explicit (`QUEUED`, then update with provider result and timestamps).
- Do use shared invoice-creation service instead of duplicating create logic across modules. - Do use shared invoice-creation service instead of duplicating create logic across modules.
@@ -67,6 +219,7 @@ Operational guide for AI/coding agents working in `consumer_api`.
- Do keep Prisma queries tight with `select`/`include` only for fields you actually use. - Do keep Prisma queries tight with `select`/`include` only for fields you actually use.
### Don't ### Don't
- Don't pass undefined/out-of-scope variables in TSP flows (common regressions: `invoice_id`, `attemptId`, `pos_id` mismatches). - Don't pass undefined/out-of-scope variables in TSP flows (common regressions: `invoice_id`, `attemptId`, `pos_id` mismatches).
- Don't leave placeholder query blocks (for example empty `select: {}`) in production code. - Don't leave placeholder query blocks (for example empty `select: {}`) in production code.
- Don't mix method semantics (`send` vs `originalSend`) across services without verifying signatures. - Don't mix method semantics (`send` vs `originalSend`) across services without verifying signatures.
@@ -74,12 +227,14 @@ Operational guide for AI/coding agents working in `consumer_api`.
- Don't change invoice type semantics (`ORIGINAL`, `CORRECTION`, `REVOKE`) implicitly. - Don't change invoice type semantics (`ORIGINAL`, `CORRECTION`, `REVOKE`) implicitly.
### Common Pitfalls To Recheck ### Common Pitfalls To Recheck
- Incorrect relation field names (`tax_id` on wrong model, missing relation selects). - Incorrect relation field names (`tax_id` on wrong model, missing relation selects).
- Building payloads from the wrong invoice (must match the expected new/ref invoice in each flow). - Building payloads from the wrong invoice (must match the expected new/ref invoice in each flow).
- Creating attempts without persisting request payload and final response payload. - Creating attempts without persisting request payload and final response payload.
- Mismatch between DTO shapes and shared service input contracts. - Mismatch between DTO shapes and shared service input contracts.
## Thread Notes (May 2026) ## Thread Notes (May 2026)
- Shared sale-invoice creation was introduced and must be injected/exported correctly in consuming modules (example failure: `UnknownDependenciesException` for `SharedSaleInvoiceCreateService`). - Shared sale-invoice creation was introduced and must be injected/exported correctly in consuming modules (example failure: `UnknownDependenciesException` for `SharedSaleInvoiceCreateService`).
- In `SalesInvoiceTspService.revoke`, prepare all creation/update data from `relatedInvoice` (no external `dataToUpdate` argument expected). - In `SalesInvoiceTspService.revoke`, prepare all creation/update data from `relatedInvoice` (no external `dataToUpdate` argument expected).
- Prisma client is generated to `src/generated/prisma` via: - Prisma client is generated to `src/generated/prisma` via:
@@ -94,6 +249,7 @@ Operational guide for AI/coding agents working in `consumer_api`.
- For heavy license provisioning (100+), request path should not synchronously insert all licenses; queue/background + batching is required. - For heavy license provisioning (100+), request path should not synchronously insert all licenses; queue/background + batching is required.
## Migration Drift Playbook (Prisma/MySQL) ## Migration Drift Playbook (Prisma/MySQL)
- If Prisma reports `modified after applied`, never edit an already-applied migration in-place for shared environments; create a new forward migration instead. - If Prisma reports `modified after applied`, never edit an already-applied migration in-place for shared environments; create a new forward migration instead.
- If migration history and DB drift mismatch: - If migration history and DB drift mismatch:
1. Verify local migration folders are complete and ordered. 1. Verify local migration folders are complete and ordered.
@@ -104,6 +260,7 @@ Operational guide for AI/coding agents working in `consumer_api`.
- `prisma migrate status` may show up-to-date while `migrate dev` still detects drift (shadow DB/application history issue); treat `migrate dev` output as source of truth for fixing local history. - `prisma migrate status` may show up-to-date while `migrate dev` still detects drift (shadow DB/application history issue); treat `migrate dev` output as source of truth for fixing local history.
## Redis Cache Conventions (May 2026) ## Redis Cache Conventions (May 2026)
- Use `RedisKeyMaker` in `src/common/utils/redis-key-maker.util.ts` for all cache keys and wildcard patterns; do not inline key strings in services. - Use `RedisKeyMaker` in `src/common/utils/redis-key-maker.util.ts` for all cache keys and wildcard patterns; do not inline key strings in services.
- Keep invalidation domain-based (for example `src/modules/admin/guilds/cache/*`, `src/modules/admin/partners/cache/*`, `src/modules/pos/cache/*`) and avoid duplicating delete logic across modules. - Keep invalidation domain-based (for example `src/modules/admin/guilds/cache/*`, `src/modules/admin/partners/cache/*`, `src/modules/pos/cache/*`) and avoid duplicating delete logic across modules.
- For list APIs that are expensive or frequently read, prefer read-through cache with TTL and invalidation on every related write path. - For list APIs that are expensive or frequently read, prefer read-through cache with TTL and invalidation on every related write path.
File diff suppressed because it is too large Load Diff
@@ -57,13 +57,10 @@ export class GoodsService {
async findAll(guildId: string) { async findAll(guildId: string) {
const cacheKey = RedisKeyMaker.guildGoodsList(guildId) const cacheKey = RedisKeyMaker.guildGoodsList(guildId)
const cached = await this.redisService.getJson<{ goods: unknown[]; total: number }>( return this.redisService.getAndSet(
cacheKey, cacheKey,
) 'paginate',
if (cached) { async () => {
return ResponseMapper.paginate(cached.goods, { total: cached.total })
}
const [goods, total] = await this.prisma.$transaction([ const [goods, total] = await this.prisma.$transaction([
this.prisma.good.findMany({ this.prisma.good.findMany({
where: this.defaultWhere(guildId), where: this.defaultWhere(guildId),
@@ -72,11 +69,10 @@ export class GoodsService {
this.prisma.good.count(), this.prisma.good.count(),
]) ])
await this.redisService.setJson(cacheKey, { goods, total }, this.listCacheTtlSeconds) return { items: goods, total }
},
return ResponseMapper.paginate(goods, { this.listCacheTtlSeconds,
total, )
})
} }
async findOne(guild_id: string, id: string) { async findOne(guild_id: string, id: string) {
@@ -31,20 +31,20 @@ export class StockKeepingUnitsService {
async findAll(guild_id: string) { async findAll(guild_id: string) {
const cacheKey = this.getListCacheKey(guild_id) const cacheKey = this.getListCacheKey(guild_id)
const cached = await this.redisService.getJson<unknown[]>(cacheKey) return await this.redisService.getAndSet(
if (cached) { cacheKey,
return ResponseMapper.list(cached) 'list',
} async () => {
const items = await this.prisma.stockKeepingUnits.findMany({ const items = await this.prisma.stockKeepingUnits.findMany({
where: { guild_id }, where: { guild_id },
orderBy: { created_at: 'desc' }, orderBy: { created_at: 'desc' },
}) })
const mappedData = items.map(this.mapData) const mappedData = items.map(this.mapData)
await this.redisService.setJson(cacheKey, mappedData, this.listCacheTtlSeconds) return mappedData
},
return ResponseMapper.list(mappedData) this.listCacheTtlSeconds,
)
} }
async create(guild_id: string, data: CreateStockKeepingUnitDto) { async create(guild_id: string, data: CreateStockKeepingUnitDto) {
@@ -17,14 +17,6 @@ export class PartnerActivatedLicensesService {
) {} ) {}
async findAll(partner_id: string, page = 1, perPage = 10) { async findAll(partner_id: string, page = 1, perPage = 10) {
const cacheKey = RedisKeyMaker.partnerActivatedLicensesList(partner_id, page, perPage)
const cached = await this.redisService.getJson<{ data: unknown[]; total: number }>(
cacheKey,
)
if (cached) {
return ResponseMapper.paginate(cached.data, { page, perPage, total: cached.total })
}
const defaultWhere: LicenseActivationWhereInput = { const defaultWhere: LicenseActivationWhereInput = {
license: { license: {
charge_transaction: { charge_transaction: {
@@ -32,6 +24,8 @@ export class PartnerActivatedLicensesService {
}, },
}, },
} }
const cacheKey = RedisKeyMaker.partnerActivatedLicensesList(partner_id, page, perPage)
return await this.redisService.getAndSet(cacheKey, 'paginate', async () => {
const [activatedLicense, total] = await this.prisma.$transaction(async tx => [ const [activatedLicense, total] = await this.prisma.$transaction(async tx => [
await tx.licenseActivation.findMany({ await tx.licenseActivation.findMany({
where: defaultWhere, where: defaultWhere,
@@ -87,13 +81,12 @@ export class PartnerActivatedLicensesService {
}, },
} }
}) })
return {
await this.redisService.setJson(cacheKey, { data: mappedLicenses, total }, 300) items: mappedLicenses,
return ResponseMapper.paginate(mappedLicenses, {
page, page,
perPage, perPage,
total, total,
}
}) })
} }
@@ -65,17 +65,11 @@ export class PartnerLicenseChargeTransactionService {
page, page,
perPage, perPage,
) )
const cached = await this.redisService.getJson<{ data: unknown[]; total: number }>(
cacheKey,
)
if (cached) {
return ResponseMapper.paginate(cached.data, { page, perPage, total: cached.total })
}
const defaultWhere: LicenseChargeTransactionWhereInput = { const defaultWhere: LicenseChargeTransactionWhereInput = {
partner_id, partner_id,
} }
return await this.redisService.getAndSet(cacheKey, 'paginate', async () => {
const [transactions, total] = await this.prisma.$transaction(async tx => [ const [transactions, total] = await this.prisma.$transaction(async tx => [
await tx.licenseChargeTransaction.findMany({ await tx.licenseChargeTransaction.findMany({
where: defaultWhere, where: defaultWhere,
@@ -89,22 +83,18 @@ export class PartnerLicenseChargeTransactionService {
]) ])
const mappedTransactions = transactions.map(this.mappedTransaction) const mappedTransactions = transactions.map(this.mappedTransaction)
await this.redisService.setJson(cacheKey, { data: mappedTransactions, total }, 300) return {
items: mappedTransactions,
return ResponseMapper.paginate(mappedTransactions, {
page, page,
perPage, perPage,
total, total,
}
}) })
} }
async findOne(partner_id: string, id: string) { async findOne(partner_id: string, id: string) {
const cacheKey = RedisKeyMaker.partnerLicenseChargeTransactionDetail(partner_id, id) const cacheKey = RedisKeyMaker.partnerLicenseChargeTransactionDetail(partner_id, id)
const cached = await this.redisService.getJson<unknown>(cacheKey) return await this.redisService.getAndSet(cacheKey, 'single', async () => {
if (cached) {
return ResponseMapper.single(cached)
}
const transaction = await this.prisma.licenseChargeTransaction.findUniqueOrThrow({ const transaction = await this.prisma.licenseChargeTransaction.findUniqueOrThrow({
where: { where: {
id, id,
@@ -112,9 +102,8 @@ export class PartnerLicenseChargeTransactionService {
}, },
select: this.defaultSelect, select: this.defaultSelect,
}) })
const mappedTransaction = this.mappedTransaction(transaction) return this.mappedTransaction(transaction)
await this.redisService.setJson(cacheKey, mappedTransaction, 300) })
return ResponseMapper.single(mappedTransaction)
} }
async create(partner_id: string, data: ChargeLicenseDto) { async create(partner_id: string, data: ChargeLicenseDto) {
+6 -19
View File
@@ -143,39 +143,26 @@ export class PartnersService {
async findAll() { async findAll() {
const cacheKey = RedisKeyMaker.partnersList() const cacheKey = RedisKeyMaker.partnersList()
const cached = await this.redisService.getJson<unknown[]>(cacheKey) return await this.redisService.getAndSet(cacheKey, 'list', async () => {
if (cached) {
console.log('cached', cached)
return ResponseMapper.list(cached)
}
const partners = await this.prisma.partner.findMany({ const partners = await this.prisma.partner.findMany({
select: this.defaultSelect, select: this.defaultSelect,
}) })
const mappedPartners = partners.map(this.mapPartner) return partners.map(this.mapPartner)
await this.redisService.setJson(cacheKey, mappedPartners, 300) })
return ResponseMapper.list(mappedPartners)
} }
async findOne(id: string) { async findOne(id: string) {
const cacheKey = RedisKeyMaker.partnerDetail(id) const cacheKey = RedisKeyMaker.partnerDetail(id)
const cached = await this.redisService.getJson<unknown>(cacheKey)
if (cached) {
return ResponseMapper.single(cached)
}
return await this.redisService.getAndSet(cacheKey, 'single', async () => {
const partner = await this.prisma.partner.findUniqueOrThrow({ const partner = await this.prisma.partner.findUniqueOrThrow({
where: { id }, where: { id },
select: this.defaultSelect, select: this.defaultSelect,
}) })
const mappedPartner = this.mapPartner(partner) return this.mapPartner(partner)
await this.redisService.setJson(cacheKey, mappedPartner, 300) })
return ResponseMapper.single(mappedPartner)
} }
async create(data: CreatePartnerDto, logo?: any) { async create(data: CreatePartnerDto, logo?: any) {
@@ -61,34 +61,23 @@ export class BusinessActivitiesService {
async findAll(consumer_id: string) { async findAll(consumer_id: string) {
const cacheKey = RedisKeyMaker.consumerBusinessActivitiesList(consumer_id) const cacheKey = RedisKeyMaker.consumerBusinessActivitiesList(consumer_id)
const cached = await this.redisService.getJson<unknown[]>(cacheKey) return await this.redisService.getAndSet(cacheKey, 'list', async () => {
if (cached) { return await this.businessActivitiesQueryService.findAllByConsumer(
return ResponseMapper.list(cached)
}
const businessActivities =
await this.businessActivitiesQueryService.findAllByConsumer(
consumer_id, consumer_id,
this.defaultSelect, this.defaultSelect,
) )
await this.redisService.setJson(cacheKey, businessActivities, this.cacheTtlSeconds) })
return ResponseMapper.list(businessActivities)
} }
async findOne(consumer_id: string, id: string) { async findOne(consumer_id: string, id: string) {
const cacheKey = RedisKeyMaker.consumerBusinessActivityInfo(consumer_id, id) const cacheKey = RedisKeyMaker.consumerBusinessActivityInfo(consumer_id, id)
const cached = await this.redisService.getJson<unknown>(cacheKey) return await this.redisService.getAndSet(cacheKey, 'list', async () => {
if (cached) { return await this.businessActivitiesQueryService.findOneByConsumer(
return ResponseMapper.single(cached)
}
const businessActivity = await this.businessActivitiesQueryService.findOneByConsumer(
consumer_id, consumer_id,
id, id,
this.defaultSelect, this.defaultSelect,
) )
await this.redisService.setJson(cacheKey, businessActivity, this.cacheTtlSeconds) })
return ResponseMapper.single(businessActivity)
} }
async update(consumer_id: string, id: string, data: any) { async update(consumer_id: string, id: string, data: any) {
+3 -7
View File
@@ -21,11 +21,8 @@ export class ConsumerService {
async getInfo(consumer_id: string) { async getInfo(consumer_id: string) {
const cacheKey = RedisKeyMaker.consumerInfo(consumer_id) const cacheKey = RedisKeyMaker.consumerInfo(consumer_id)
const cached = await this.redisService.getJson<unknown>(cacheKey)
if (cached) {
return ResponseMapper.single(cached)
}
return await this.redisService.getAndSet(cacheKey, 'single', async () => {
const consumer = await this.prisma.consumer.findUniqueOrThrow({ const consumer = await this.prisma.consumer.findUniqueOrThrow({
where: { where: {
id: consumer_id, id: consumer_id,
@@ -37,9 +34,8 @@ export class ConsumerService {
}, },
}) })
const mappedConsumer = consumer_mappersUtil(consumer) return consumer_mappersUtil(consumer)
await this.redisService.setJson(cacheKey, mappedConsumer, this.infoCacheTtlSeconds) })
return ResponseMapper.single(mappedConsumer)
} }
async updateInfo(consumer_id: string, data: UpdateConsumerInfoDto) { async updateInfo(consumer_id: string, data: UpdateConsumerInfoDto) {
@@ -103,13 +103,7 @@ export class BusinessActivitiesService {
page, page,
perPage, perPage,
) )
const cached = await this.redisService.getJson<{ items: unknown[]; total: number }>( return await this.redisService.getAndSet(cacheKey, 'paginate', async () => {
cacheKey,
)
if (cached) {
return ResponseMapper.paginate(cached.items, { total: cached.total, page, perPage })
}
const where = this.defaultWhere(partner_id, consumer_id) const where = this.defaultWhere(partner_id, consumer_id)
const [businessActivities, total] = await this.prisma.$transaction(async tx => [ const [businessActivities, total] = await this.prisma.$transaction(async tx => [
await tx.businessActivity.findMany({ await tx.businessActivity.findMany({
@@ -121,12 +115,14 @@ export class BusinessActivitiesService {
await tx.businessActivity.count({ where }), await tx.businessActivity.count({ where }),
]) ])
const mappedItems = businessActivities.map(this.prepareBusinessActivityResponse) const mappedItems = businessActivities.map(this.prepareBusinessActivityResponse)
await this.redisService.setJson(
cacheKey, return {
{ items: mappedItems, total }, items: mappedItems,
this.cacheTtlSeconds, total,
) page,
return ResponseMapper.paginate(mappedItems, { total, page, perPage }) perPage,
}
})
} }
async findOne(partner_id: string, consumer_id: string, id: string) { async findOne(partner_id: string, consumer_id: string, id: string) {
@@ -135,11 +131,7 @@ export class BusinessActivitiesService {
consumer_id, consumer_id,
id, id,
) )
const cached = await this.redisService.getJson<unknown>(cacheKey) return await this.redisService.getAndSet(cacheKey, 'single', async () => {
if (cached) {
return ResponseMapper.single(cached)
}
const businessActivity = await this.prisma.businessActivity.findUnique({ const businessActivity = await this.prisma.businessActivity.findUnique({
where: { where: {
...this.defaultWhere(partner_id, consumer_id), ...this.defaultWhere(partner_id, consumer_id),
@@ -147,9 +139,8 @@ export class BusinessActivitiesService {
}, },
select: this.defaultSelect, select: this.defaultSelect,
}) })
const mappedItem = this.prepareBusinessActivityResponse(businessActivity) return this.prepareBusinessActivityResponse(businessActivity)
await this.redisService.setJson(cacheKey, mappedItem, this.cacheTtlSeconds) })
return ResponseMapper.single(mappedItem)
} }
async create( async create(
@@ -65,13 +65,7 @@ export class BusinessActivityComplexesService {
page, page,
perPage, perPage,
) )
const cached = await this.redisService.getJson<{ items: unknown[]; total: number }>( return await this.redisService.getAndSet(cacheKey, 'paginate', async () => {
cacheKey,
)
if (cached) {
return ResponseMapper.paginate(cached.items, { total: cached.total, page, perPage })
}
const where = this.defaultWhere(partner_id, consumer_id, business_activity_id) const where = this.defaultWhere(partner_id, consumer_id, business_activity_id)
const [complexes, total] = await this.prisma.$transaction(async tx => [ const [complexes, total] = await this.prisma.$transaction(async tx => [
await tx.complex.findMany({ await tx.complex.findMany({
@@ -97,12 +91,14 @@ export class BusinessActivityComplexesService {
pos_count: _count.pos_list, pos_count: _count.pos_list,
} }
}) })
await this.redisService.setJson(
cacheKey, return {
{ items: mappedComplexes, total }, items: mappedComplexes,
this.cacheTtlSeconds, total,
) page,
return ResponseMapper.paginate(mappedComplexes, { total, page, perPage }) perPage,
}
})
} }
async findOne( async findOne(
@@ -117,11 +113,7 @@ export class BusinessActivityComplexesService {
business_activity_id, business_activity_id,
id, id,
) )
const cached = await this.redisService.getJson<unknown>(cacheKey) return await this.redisService.getAndSet(cacheKey, 'single', async () => {
if (cached) {
return ResponseMapper.single(cached)
}
const complex = await this.prisma.complex.findFirst({ const complex = await this.prisma.complex.findFirst({
where: { where: {
...this.defaultWhere(partner_id, consumer_id, business_activity_id), ...this.defaultWhere(partner_id, consumer_id, business_activity_id),
@@ -134,8 +126,8 @@ export class BusinessActivityComplexesService {
throw new BadRequestException('شعبه مورد نظر یافت نشد.') throw new BadRequestException('شعبه مورد نظر یافت نشد.')
} }
await this.redisService.setJson(cacheKey, complex, this.cacheTtlSeconds) return complex
return ResponseMapper.single(complex) })
} }
async create( async create(
@@ -64,13 +64,7 @@ export class PartnerConsumersService {
async findAll(partner_id: string, page = 1, perPage = 10) { async findAll(partner_id: string, page = 1, perPage = 10) {
const cacheKey = RedisKeyMaker.partnerConsumersList(partner_id, page, perPage) const cacheKey = RedisKeyMaker.partnerConsumersList(partner_id, page, perPage)
const cached = await this.redisService.getJson<{ items: unknown[]; total: number }>( return await this.redisService.getAndSet(cacheKey, 'paginate', async () => {
cacheKey,
)
if (cached) {
return ResponseMapper.paginate(cached.items, { total: cached.total, page, perPage })
}
const [consumers, total] = await this.prisma.$transaction(async tx => [ const [consumers, total] = await this.prisma.$transaction(async tx => [
await tx.consumer.findMany({ await tx.consumer.findMany({
where: this.defaultWhere(partner_id), where: this.defaultWhere(partner_id),
@@ -82,26 +76,20 @@ export class PartnerConsumersService {
where: this.defaultWhere(partner_id), where: this.defaultWhere(partner_id),
}), }),
]) ])
const mappedItems = consumers.map(mapConsumerWithLicenseUtil) const mappedConsumers = consumers.map(mapConsumerWithLicenseUtil)
await this.redisService.setJson(
cacheKey, return {
{ items: mappedItems, total }, items: mappedConsumers,
this.infoCacheTtlSeconds,
)
return ResponseMapper.paginate(mappedItems, {
total, total,
page, page,
perPage, perPage,
}
}) })
} }
async findOne(partner_id: string, consumer_id: string) { async findOne(partner_id: string, consumer_id: string) {
const cacheKey = RedisKeyMaker.partnerConsumerInfo(partner_id, consumer_id) const cacheKey = RedisKeyMaker.partnerConsumerInfo(partner_id, consumer_id)
const cached = await this.redisService.getJson<unknown>(cacheKey) return await this.redisService.getAndSet(cacheKey, 'single', async () => {
if (cached) {
return ResponseMapper.single(cached)
}
const consumer = await this.prisma.consumer.findUniqueOrThrow({ const consumer = await this.prisma.consumer.findUniqueOrThrow({
where: { where: {
...(this.defaultWhere(partner_id) as any), ...(this.defaultWhere(partner_id) as any),
@@ -110,14 +98,8 @@ export class PartnerConsumersService {
select: this.defaultSelect, select: this.defaultSelect,
}) })
const mappedConsumer = mapConsumerWithLicenseUtil(consumer) return mapConsumerWithLicenseUtil(consumer)
await this.redisService.setJson( })
RedisKeyMaker.consumerInfo(consumer_id),
mappedConsumer,
this.infoCacheTtlSeconds,
)
await this.redisService.setJson(cacheKey, mappedConsumer, this.infoCacheTtlSeconds)
return ResponseMapper.single(mappedConsumer)
} }
async create(partner_id: string, data: CreateConsumerDto) { async create(partner_id: string, data: CreateConsumerDto) {
+9 -12
View File
@@ -52,14 +52,11 @@ export class GoodsService {
guild_id: string, guild_id: string,
consumer_account_id: string, consumer_account_id: string,
) { ) {
const cacheKey = RedisKeyMaker.posGoodsList(guild_id, business_activity_id) const cacheKey = RedisKeyMaker.posGoodsList(business_activity_id, guild_id)
try { return this.redisService.getAndSet(
const cached = await this.redisService.getJson<unknown[]>(cacheKey) cacheKey,
if (cached) { 'list',
return ResponseMapper.list(cached) async () => {
}
throw new Error('Cache miss')
} catch (error) {
const goods = await this.prisma.good.findMany({ const goods = await this.prisma.good.findMany({
where: { where: {
OR: [ OR: [
@@ -89,10 +86,10 @@ export class GoodsService {
is_favorite: good.consumer_account_good_favorites.length > 0, is_favorite: good.consumer_account_good_favorites.length > 0,
})) }))
await this.redisService.setJson(cacheKey, mappedGoods, this.listCacheTtlSeconds) return mappedGoods
},
return ResponseMapper.list(mappedGoods) this.listCacheTtlSeconds,
} )
} }
async findOne(good_id: string, business_activity_id: string, guild_id: string) { async findOne(good_id: string, business_activity_id: string, guild_id: string) {
+18 -14
View File
@@ -19,11 +19,10 @@ export class PosService {
async getInfo(pos_id: string) { async getInfo(pos_id: string) {
const cacheKey = RedisKeyMaker.posInfo(pos_id) const cacheKey = RedisKeyMaker.posInfo(pos_id)
const cached = await this.redisService.getJson<unknown>(cacheKey) return await this.redisService.getAndSet(
if (cached) { cacheKey,
return ResponseMapper.single(cached) 'single',
} async () => {
const pos = await this.prisma.pos.findUniqueOrThrow({ const pos = await this.prisma.pos.findUniqueOrThrow({
where: { where: {
id: pos_id, id: pos_id,
@@ -104,8 +103,11 @@ export class PosService {
}, },
partner: license_activation?.license.charge_transaction.partner, partner: license_activation?.license.charge_transaction.partner,
} }
await this.redisService.setJson(cacheKey, payload, this.infoCacheTtlSeconds)
return ResponseMapper.single(payload) return payload
},
this.infoCacheTtlSeconds,
)
} }
async getAccessible(account_id: string) { async getAccessible(account_id: string) {
@@ -153,11 +155,10 @@ export class PosService {
async getMe(account_id: string, pos_id: string) { async getMe(account_id: string, pos_id: string) {
const cacheKey = RedisKeyMaker.posMe(account_id, pos_id) const cacheKey = RedisKeyMaker.posMe(account_id, pos_id)
const cached = await this.redisService.getJson<unknown>(cacheKey) return await this.redisService.getAndSet(
if (cached) { cacheKey,
return ResponseMapper.single(cached) 'single',
} async () => {
const pos = await this.prisma.consumerAccount.findUniqueOrThrow({ const pos = await this.prisma.consumerAccount.findUniqueOrThrow({
where: { where: {
id: account_id, id: account_id,
@@ -203,7 +204,10 @@ export class PosService {
...rest, ...rest,
consumer: consumer_mappersUtil(consumer), consumer: consumer_mappersUtil(consumer),
} }
await this.redisService.setJson(cacheKey, payload, this.meCacheTtlSeconds)
return ResponseMapper.single(payload) return payload
},
this.meCacheTtlSeconds,
)
} }
} }
+1 -1
View File
@@ -14,7 +14,7 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
password: env('DATABASE_PASSWORD'), password: env('DATABASE_PASSWORD'),
database: env('DATABASE_NAME'), database: env('DATABASE_NAME'),
port: env('DATABASE_PORT') ? Number(env('DATABASE_PORT')) : 3306, port: env('DATABASE_PORT') ? Number(env('DATABASE_PORT')) : 3306,
connectionLimit: 50, connectionLimit: 10,
}) })
const prismaOptions = getPrismaOptions() const prismaOptions = getPrismaOptions()
+148 -24
View File
@@ -1,27 +1,35 @@
import {
MapperWrapper,
Paginated,
ResponseMapper,
} from '@/common/response/response-mapper'
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common' import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'
import Redis from 'ioredis' import Redis from 'ioredis'
interface PaginatedCache<T> {
items: T[]
total: number
page?: number
perPage?: number
}
@Injectable() @Injectable()
export class RedisService implements OnModuleDestroy { export class RedisService implements OnModuleDestroy {
private static readonly scanCount = 100
private readonly logger = new Logger(RedisService.name) private readonly logger = new Logger(RedisService.name)
private readonly client: Redis private readonly client: Redis
constructor() { constructor() {
const host = process.env.REDIS_HOST || 'redis'
const port = Number(process.env.REDIS_PORT || 6379)
const password = process.env.REDIS_PASSWORD || undefined
const db = Number(process.env.REDIS_DB || 0)
this.client = new Redis({ this.client = new Redis({
host, host: process.env.REDIS_HOST || 'redis',
port, port: Number(process.env.REDIS_PORT || 6379),
password, password: process.env.REDIS_PASSWORD || undefined,
db, db: Number(process.env.REDIS_DB || 0),
lazyConnect: true, lazyConnect: true,
maxRetriesPerRequest: 3, maxRetriesPerRequest: 3,
}) })
this.client.on('error', (error) => { this.client.on('error', error => {
this.logger.error(`Redis error: ${error.message}`) this.logger.error(`Redis error: ${error.message}`)
}) })
} }
@@ -53,11 +61,7 @@ export class RedisService implements OnModuleDestroy {
} }
} }
async set( async set(key: string, value: string, ttlSeconds?: number): Promise<'OK' | null> {
key: string,
value: string,
ttlSeconds?: number,
): Promise<'OK' | null> {
const client = await this.getClient() const client = await this.getClient()
if (ttlSeconds && ttlSeconds > 0) { if (ttlSeconds && ttlSeconds > 0) {
return client.set(key, value, 'EX', ttlSeconds) return client.set(key, value, 'EX', ttlSeconds)
@@ -65,11 +69,7 @@ export class RedisService implements OnModuleDestroy {
return client.set(key, value) return client.set(key, value)
} }
async setJson( async setJson(key: string, value: unknown, ttlSeconds?: number): Promise<'OK' | null> {
key: string,
value: unknown,
ttlSeconds?: number,
): Promise<'OK' | null> {
return this.set(key, JSON.stringify(value), ttlSeconds) return this.set(key, JSON.stringify(value), ttlSeconds)
} }
@@ -101,14 +101,12 @@ export class RedisService implements OnModuleDestroy {
'MATCH', 'MATCH',
pattern, pattern,
'COUNT', 'COUNT',
100, RedisService.scanCount,
) )
cursor = nextCursor cursor = nextCursor
if (keys.length > 0) { if (keys.length > 0) {
const pipeline = client.pipeline() const results = await this.deleteKeys(client, keys)
keys.forEach(k => pipeline.del(k))
const results = await pipeline.exec()
if (results) { if (results) {
deletedCount += results.reduce((sum, [, value]) => { deletedCount += results.reduce((sum, [, value]) => {
return sum + (typeof value === 'number' ? value : 0) return sum + (typeof value === 'number' ? value : 0)
@@ -127,6 +125,132 @@ export class RedisService implements OnModuleDestroy {
return deletedCounts.reduce((sum, count) => sum + count, 0) return deletedCounts.reduce((sum, count) => sum + count, 0)
} }
async getAndSet<T>(
key: string,
type: 'single',
callback: () => Promise<T>,
ttlSeconds?: number,
): Promise<MapperWrapper<T>>
async getAndSet<T>(
key: string,
type: 'list',
callback: () => Promise<T[]>,
ttlSeconds?: number,
): Promise<MapperWrapper<T>>
async getAndSet<T>(
key: string,
type: 'paginate',
callback: () => Promise<PaginatedCache<T>>,
ttlSeconds?: number,
): Promise<Paginated<T>>
async getAndSet<T>(
key: string,
type: 'single' | 'list' | 'paginate',
callback: () => Promise<T | T[] | PaginatedCache<T>>,
ttlSeconds?: number,
): Promise<MapperWrapper<T> | Paginated<T>> {
switch (type) {
case 'single':
return this.getAndSetSingle(key, callback as () => Promise<T>, ttlSeconds)
case 'list':
return this.getAndSetList(key, callback as () => Promise<T[]>, ttlSeconds)
case 'paginate':
return this.getAndSetPaginate(
key,
callback as () => Promise<PaginatedCache<T>>,
ttlSeconds,
)
default: {
const neverType: never = type
throw new Error(`Unsupported cache type: ${neverType}`)
}
}
}
private async getAndSetSingle<T>(
key: string,
callback: () => Promise<T>,
ttlSeconds?: number,
): Promise<MapperWrapper<T>> {
const cached = await this.tryReadJson<T>(key)
if (cached !== null) {
return ResponseMapper.single(cached)
}
const data = await callback()
await this.tryWriteJson(key, data, ttlSeconds)
return ResponseMapper.single(data)
}
private async getAndSetList<T>(
key: string,
callback: () => Promise<T[]>,
ttlSeconds?: number,
): Promise<MapperWrapper<T>> {
const cached = await this.tryReadJson<T[]>(key)
if (cached !== null) {
return ResponseMapper.list(cached)
}
const data = await callback()
await this.tryWriteJson(key, data, ttlSeconds)
return ResponseMapper.list(data)
}
private async getAndSetPaginate<T>(
key: string,
callback: () => Promise<PaginatedCache<T>>,
ttlSeconds?: number,
): Promise<Paginated<T>> {
const cached = await this.tryReadJson<PaginatedCache<T>>(key)
if (cached !== null) {
return ResponseMapper.paginate(cached.items, {
total: cached.total,
page: cached.page,
perPage: cached.perPage,
})
}
const data = await callback()
await this.tryWriteJson(key, data, ttlSeconds)
return ResponseMapper.paginate(data.items, {
total: data.total,
page: data.page,
perPage: data.perPage,
})
}
private async tryReadJson<T>(key: string): Promise<T | null> {
try {
return await this.getJson<T>(key)
} catch (error) {
this.logger.warn(`Redis read failed for "${key}": ${(error as Error).message}`)
return null
}
}
private async tryWriteJson(
key: string,
value: unknown,
ttlSeconds: number = 300,
): Promise<void> {
try {
await this.setJson(key, value, ttlSeconds)
} catch (error) {
this.logger.warn(`Redis write failed for "${key}": ${(error as Error).message}`)
}
}
private async deleteKeys(
client: Redis,
keys: string[],
): Promise<Array<[Error | null, unknown]>> {
const pipeline = client.pipeline()
keys.forEach(key => pipeline.del(key))
const results = await pipeline.exec()
return results ?? []
}
async onModuleDestroy() { async onModuleDestroy() {
await this.client.quit() await this.client.quit()
} }