full app
This commit is contained in:
24
utils/billboardFreeLimit.js
Normal file
24
utils/billboardFreeLimit.js
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
|
||||
@@ -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
42
utils/walletSplit.js
Normal 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 }
|
||||
Reference in New Issue
Block a user