This commit is contained in:
payacom
2026-09-24 21:00:07 +03:30
parent 1954b2661d
commit 7f0ddfde70
113 changed files with 570 additions and 5049 deletions

View File

@@ -0,0 +1,24 @@
// سقفِ کلیِ (نه روزانه/ماهانه) بیلبورد رایگان — طبق خواسته‌ی صریحِ کاربر: ۲۰۰ تا
// در کلِ عمرِ حساب، مستقل از سهمیه‌ی مشترکِ daily_free_request (که بین پروژه/بیلبورد/
// درخواست تقسیم می‌شه) و مستقل از سقفِ عمومیِ FREE_LIFETIME_CREATION_LIMIT
// (utils/freeCreationLimit.js)
const BILLBOARD_FREE_LIMIT = 200
function canUseFreeBillboard (user) {
const used = user?.free_billboard_count || 0
return used < BILLBOARD_FREE_LIMIT
}
async function consumeFreeBillboard (user) {
if (!canUseFreeBillboard(user)) {
return { ok: false, message: `سقف ${BILLBOARD_FREE_LIMIT} بیلبورد رایگان به پایان رسیده است.` }
}
user.free_billboard_count = (user.free_billboard_count || 0) + 1
return { ok: true }
}
module.exports = {
BILLBOARD_FREE_LIMIT,
canUseFreeBillboard,
consumeFreeBillboard
}

View File

@@ -10,6 +10,7 @@ const CourseModel = require('../models/CourseModel')
const AcademyCategoryModel = require('../models/AcademyCategoryModel')
const AdvertisingModel = require('../models/AdvertisingModel')
const AdvertisingCategoryModel = require('../models/AdvertisingCategoryModel')
const { haversineMeters } = require('./nearbyPosts')
const EXPERTISE_ALIASES = {
مدل: ['مدل', 'مدلینگ'],
@@ -252,12 +253,18 @@ async function buildUserExploreSignals(userId) {
viewedAuthors: {},
likedPostIds: [],
commentedPostIds: [],
viewerCooperationType: null,
viewerCooperationAbroad: null,
viewerLat: null,
viewerLng: null,
viewerProvinceId: null,
viewerCityId: null,
isColdStart: true
}
}
const user = await UserModel.findById(userId)
.select('expertise sub_expertise algorithm_reset_at')
.select('expertise sub_expertise algorithm_reset_at cooperation_type cooperation_abroad lat lng province city')
.lean()
// بعد از «ریست الگوریتم»، لایک/کامنت/آفرِ قبل از این تاریخ نباید توی ترجیحات
@@ -362,6 +369,12 @@ async function buildUserExploreSignals(userId) {
}
})
// «لوکیشنِ روشن» یعنی کاربر توی آدرس/محل‌سکونتش (verify/edit address — addressController.js)
// مختصاتِ معتبر ثبت کرده؛ توی این بک‌اند هیچ flagِ جداگانه‌ی «اجازه‌ی دسترسی به GPS»
// وجود نداره، پس همون معیارِ عملیِ nearbyPosts.js/reelsTabFeed.js رو تکرار می‌کنیم
const viewerLat = Number.isFinite(parseFloat(user?.lat)) ? parseFloat(user.lat) : null
const viewerLng = Number.isFinite(parseFloat(user?.lng)) ? parseFloat(user.lng) : null
return {
userExpertise: user?.expertise || null,
userSubExpertise: user?.sub_expertise || [],
@@ -372,6 +385,14 @@ async function buildUserExploreSignals(userId) {
viewedAuthors,
likedPostIds,
commentedPostIds: comments.map((c) => String(c.post)),
// طبق خواسته‌ی کاربر: نوع همکاری (وقتی توی «ویرایش نوع همکاری» مشخص می‌شه) و
// لوکیشن/محلِ سکونت باید روی الگوریتم اثر بذارن — پایین‌تر در scorePost استفاده می‌شن
viewerCooperationType: user?.cooperation_type || null,
viewerCooperationAbroad: user?.cooperation_abroad ?? null,
viewerLat,
viewerLng,
viewerProvinceId: user?.province?.id ?? null,
viewerCityId: user?.city?.id ?? null,
isColdStart: !hasEnoughUserSignals({
likedPostIds,
commentedPostIds: comments.map((c) => String(c.post)),
@@ -551,6 +572,42 @@ function scorePost(post, author, signals, options = {}) {
score += 16
}
// نوع همکاری: اگه کاربر فقط دنبالِ همکاری با افرادِ تاییدشده‌ست، نویسنده‌های
// تاییدشده کمی جلوتر بیفتن (نه فیلترِ کامل، فقط یه سوگیریِ خفیف توی رتبه‌بندی)
if (signals.viewerCooperationType === 'verified') {
const authorVerified = Boolean(author?.is_verified || author?.verify_badge)
score += authorVerified ? 10 : -10
}
// نوع همکاری: اگه کاربر گفته فقط توی همون شهرِ خودش دنبالِ همکاریه
// (cooperation_abroad=false)، محتوایِ هم‌شهری/هم‌استانی‌ها امتیازِ بیشتری بگیرن
if (signals.viewerCooperationAbroad === false && signals.viewerProvinceId) {
const authorProvinceId = author?.province?.id ?? null
const authorCityId = author?.city?.id ?? null
const sameProvince =
authorProvinceId != null && String(authorProvinceId) === String(signals.viewerProvinceId)
const sameCity =
signals.viewerCityId != null &&
authorCityId != null &&
String(authorCityId) === String(signals.viewerCityId)
if (sameCity) score += 20
else if (sameProvince) score += 10
else score -= 8
}
// محلِ سکونت: هرچی نویسنده به محلِ سکونتِ کاربر نزدیک‌تر باشه امتیازِ بیشتری
// می‌گیره (افولِ نمایی، شبیهِ interactionDecay) — فقط وقتی هر دو مختصاتِ معتبر
// دارن و نویسنده لوکیشنش رو مخفی نکرده (show_location)
if (signals.viewerLat != null && signals.viewerLng != null && author?.show_location !== false) {
const authorLat = Number.isFinite(parseFloat(author?.lat)) ? parseFloat(author.lat) : null
const authorLng = Number.isFinite(parseFloat(author?.lng)) ? parseFloat(author.lng) : null
if (authorLat != null && authorLng != null) {
const distanceKm =
haversineMeters(signals.viewerLat, signals.viewerLng, authorLat, authorLng) / 1000
score += 18 * Math.exp(-distanceKm / 150)
}
}
if (post.type && signals.contentTypeWeight[post.type]) {
score += signals.contentTypeWeight[post.type] * 2
}

View File

@@ -7,7 +7,7 @@ const OFFER_REPLY_WINDOW_MS = 12 * 60 * 60 * 1000
/**
* Called whenever a chat message is sent. If there's a pending paid Offer
* between these two users, tracks the requester's first direct message and
* the receiver's reply — the receiver's 30% commission is only credited if
* the receiver's reply — the receiver's 50% commission is only credited if
* they reply within 12 hours of that first message.
*/
async function trackOfferMessageForCommission (io, senderId, receiverId) {
@@ -49,7 +49,7 @@ async function trackOfferMessageForCommission (io, senderId, receiverId) {
amount: offer.pendingCommissionAmount,
referenceType: 'payment',
referenceId: offer._id,
description: 'سهم دریافتی از درخواست همکاری (۳۰٪، پس از پاسخ به‌موقع)'
description: 'سهم دریافتی از درخواست همکاری (۵۰٪، پس از پاسخ به‌موقع)'
})
if (io) {
@@ -57,7 +57,7 @@ async function trackOfferMessageForCommission (io, senderId, receiverId) {
io.to(`user:${String(offer.receiver)}`).emit('notification', {
type: 'offer_commission',
title: 'واریز سهم درخواست همکاری',
description: 'با پاسخ به‌موقع، ۳۰٪ مبلغ درخواست همکاری به کیف پول شما واریز شد.'
description: 'با پاسخ به‌موقع، ۵۰٪ مبلغ درخواست همکاری به کیف پول شما واریز شد.'
})
} catch (_) {}
}

View File

@@ -12,7 +12,7 @@ async function attachPostEngagementFields(posts, viewerId) {
const postIds = posts.map((p) => p._id)
const authorIds = [...new Set(posts.map((p) => String(p.user_id)))]
const [bookmarkAgg, sendAgg, shareAgg, viewAgg, authorDocs, userBookmarks, userFollows, activeStories] = await Promise.all([
const [bookmarkAgg, sendAgg, shareAgg, viewAgg, offerAgg, authorDocs, userBookmarks, userFollows, activeStories] = await Promise.all([
PostBookmarkModel.aggregate([
{ $match: { postId: { $in: postIds } } },
{ $group: { _id: '$postId', count: { $sum: 1 } } },
@@ -35,6 +35,16 @@ async function attachPostEngagementFields(posts, viewerId) {
},
{ $group: { _id: '$targetId', count: { $sum: 1 } } },
]),
ExploreInteractionModel.aggregate([
{
$match: {
targetType: 'post',
targetId: { $in: postIds },
action: 'offer',
},
},
{ $group: { _id: '$targetId', count: { $sum: 1 } } },
]),
UserModel.find({ _id: { $in: authorIds } })
.select('_id allow_save_posts')
.lean(),
@@ -68,6 +78,9 @@ async function attachPostEngagementFields(posts, viewerId) {
const viewsMap = Object.fromEntries(
viewAgg.map((v) => [String(v._id), v.count])
)
const offersMap = Object.fromEntries(
offerAgg.map((o) => [String(o._id), o.count])
)
const allowSaveMap = Object.fromEntries(
authorDocs.map((u) => [String(u._id), u.allow_save_posts !== false])
)
@@ -95,6 +108,7 @@ async function attachPostEngagementFields(posts, viewerId) {
sendsCount: sendsMap[String(post._id)] || 0,
sharesCount: sharesMap[String(post._id)] || 0,
viewsCount: viewsMap[String(post._id)] || 0,
offersCount: offersMap[String(post._id)] || 0,
author_allow_save: allowSaveMap[String(post.user_id)] !== false,
is_saved: savedSet.has(String(post._id)),
is_following: followSet.has(String(post.user_id)),

42
utils/walletSplit.js Normal file
View File

@@ -0,0 +1,42 @@
const WalletTransactionModel = require('../models/WalletTransactionModel')
const { getBucketBalance } = require('../controllers/application/wallet/walletController')
/**
* وقتی کاربر موقع خرید تیکِ «استفاده از موجودی کیف پول» رو می‌زنه (useWallet=true)،
* تا هر مقدار که سبدِ «charged» (شارژِ دستیِ کیف پول، همون چیزی که کاربر خودش واریز
* کرده — نه سبدهای درآمدیِ shop/academy/project/booking) پوشش بده از همونجا کسر
* می‌شه و فقط باقی‌مونده (اگه چیزی مونده باشه) از درگاه زرین‌پال گرفته می‌شه. اگه
* useWallet نباشه یا موجودی صفر باشه، دقیقاً مثلِ قبل کاملش از درگاه پرداخت می‌شه.
*
* عمداً همینجا چیزی کسر نمی‌کنه (فقط محاسبه) — کسرِ واقعی رو خودِ فراخوان، فقط لحظه‌ای
* که پرداخت قطعاً موفق شده (یا کلاً از درگاه رد نشده، یعنی fullyCovered) صدا می‌زنه؛
* این‌طوری اگه کاربر پرداختِ درگاه رو نصفه ول کنه، هیچ مبلغی از کیف پول کم نشده که
* لازم باشه برگردونده بشه
*/
async function resolveWalletSplit (userId, amount, useWallet) {
if (!useWallet) {
return { walletAmount: 0, gatewayAmount: amount, fullyCovered: false }
}
const balance = await getBucketBalance(userId, 'charged')
if (!balance || balance <= 0) {
return { walletAmount: 0, gatewayAmount: amount, fullyCovered: false }
}
const walletAmount = Math.min(balance, amount)
const gatewayAmount = amount - walletAmount
return { walletAmount, gatewayAmount, fullyCovered: gatewayAmount <= 0 }
}
async function debitWalletForPurchase (userId, walletAmount, { referenceType, referenceId, description }) {
if (!walletAmount || walletAmount <= 0) return
await WalletTransactionModel.create({
user: userId,
bucket: 'charged',
type: 'debit',
amount: walletAmount,
referenceType,
referenceId,
description
})
}
module.exports = { resolveWalletSplit, debitWalletForPurchase }