This commit is contained in:
payacom
2026-09-24 21:03:27 +03:30
parent 7f0ddfde70
commit 806471241e
43 changed files with 2166 additions and 144 deletions

View File

@@ -0,0 +1,388 @@
const axios = require('axios')
const mongoose = require('mongoose')
const AdModel = require('../../../models/AdModel')
const PostModel = require('../../../models/PostModel')
const StoryModel = require('../../../models/StoryModel')
const UserModel = require('../../../models/UserModel')
const { resolveAdPrice } = require('../../../utils/adPricing')
const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit')
const { deliverNotificationAd, trackAdEvent, findActiveAd, buildAdLinkedContentItem } = require('../../../utils/adDelivery')
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
const SPONSORED_TYPES = ['project', 'product', 'shop', 'billboard']
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE
const AD_CAMPAIGN_DURATION_DAYS = 7
const AD_TYPES = AdModel.AD_TYPES
function toRial (toman) {
return Math.round(Number(toman) * 10)
}
function normalizeTargeting (body) {
// برای POST (createAd) یه آرایه‌ی واقعیه (JSON body)؛ برای GET (getAdPricing) کوئری‌استرینگ
// که فرانت با کاما جدا می‌فرسته — هر دو شکل رو قبول می‌کنیم
let subExpertise = []
if (Array.isArray(body.target_sub_expertise)) {
subExpertise = body.target_sub_expertise.filter(Boolean)
} else if (typeof body.target_sub_expertise === 'string' && body.target_sub_expertise) {
subExpertise = body.target_sub_expertise.split(',').map((s) => s.trim()).filter(Boolean)
}
return {
target_country_id: body.target_country_id != null && body.target_country_id !== '' ? Number(body.target_country_id) : null,
target_province_id: body.target_province_id != null && body.target_province_id !== '' ? Number(body.target_province_id) : null,
target_city_id: body.target_city_id != null && body.target_city_id !== '' ? Number(body.target_city_id) : null,
target_expertise: body.target_expertise ? String(body.target_expertise).trim() : null,
target_sub_expertise: subExpertise,
target_gender: ['female', 'male'].includes(body.target_gender) ? body.target_gender : 'any'
}
}
/** GET /ads/pricing — کاربر با هر انتخابی توی ویزارد، قیمتِ همون سناریو رو زنده می‌بینه */
const getAdPricing = async (req, res, next) => {
try {
const { ad_type: adType } = req.query
if (!AD_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
const targeting = normalizeTargeting(req.query)
const price = await resolveAdPrice(adType, {
provinceId: targeting.target_province_id,
gender: targeting.target_gender === 'any' ? null : targeting.target_gender,
expertise: targeting.target_expertise,
subExpertise: targeting.target_sub_expertise
})
return res.json({ price })
} catch (error) {
next(error)
}
}
async function activateAd (ad) {
ad.status = 'active'
ad.started_at = new Date()
ad.expires_at = new Date(Date.now() + AD_CAMPAIGN_DURATION_DAYS * 24 * 60 * 60 * 1000)
await ad.save()
if (ad.ad_type === 'notification') {
deliverNotificationAd(ad).catch((error) => console.error('deliverNotificationAd error:', error.message))
}
}
/** POST /ads — ساخت تبلیغ؛ عین سفارش فروشگاه/دوره، اول کیف پول رو چک می‌کنه */
const createAd = async (req, res, next) => {
try {
const { ad_type: adType, useWallet } = req.body
if (!AD_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
let contentPostId = null
let contentStoryId = null
let contentMediaPath = null
let contentMediaType = null
let contentOverlays = null
let contentProjectId = null
let contentShopProductId = null
let contentShopId = null
let contentBillboardId = null
let notificationTitle = null
let notificationBody = null
// نگاشتِ ad_type → {کلیدِ ورودی، تابعِ resolveLinkedEntity، پیامِ خطا} برای چهار
// نوعِ «تبلیغِ محتوای موجود» (پروژه/کالا/فروشگاه/بیلبورد) — همون منطقِ مالکیت+وضعیتی
// که ثبتِ پست (resolveLinkedEntity.js) از قبل داره، دوباره‌نویسی نشده
const LINKED_CONTENT_TYPES = {
project: { bodyKey: 'content_project_id', kind: 'project', message: 'پروژه‌ای برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این پروژه نیستید' },
product: { bodyKey: 'content_shop_product_id', kind: 'shopproduct', message: 'کالایی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این کالا نیستید' },
shop: { bodyKey: 'content_shop_id', kind: 'shop', message: 'فروشگاهی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این فروشگاه نیستید' },
billboard: { bodyKey: 'content_billboard_id', kind: 'billboard', message: 'بیلبوردی برای تبلیغ انتخاب نشده است', ownerMessage: 'شما مالک این بیلبورد نیستید' }
}
if (adType === 'first_post') {
const postId = req.body.content_post_id
if (!postId || !mongoose.Types.ObjectId.isValid(postId)) {
return res.status(422).json({ error: true, message: 'پستی برای تبلیغ انتخاب نشده است' })
}
const post = await PostModel.findById(postId).select('user_id').lean()
if (!post || String(post.user_id) !== String(req.user._id)) {
return res.status(403).json({ error: true, message: 'شما مالک این پست نیستید' })
}
contentPostId = postId
} else if (adType === 'first_story') {
const storyId = req.body.content_story_id
if (!storyId || !mongoose.Types.ObjectId.isValid(storyId)) {
return res.status(422).json({ error: true, message: 'استوری‌ای برای تبلیغ انتخاب نشده است' })
}
// مدیا/اورلی‌ها همینجا اسنپ‌شات می‌شن (نه فقط رفرنس) — استوریِ اصلی معمولاً ۲۴ساعته
// منقضی می‌شه، ولی کمپینِ تبلیغاتی باید کاملِ مدتش (activateAd) زنده بمونه
const story = await StoryModel.findById(storyId).select('user_id media_path media_type overlays').lean()
if (!story || String(story.user_id) !== String(req.user._id)) {
return res.status(403).json({ error: true, message: 'شما مالک این استوری نیستید' })
}
contentStoryId = storyId
contentMediaPath = story.media_path
contentMediaType = story.media_type
contentOverlays = story.overlays || []
} else if (adType === 'notification') {
notificationTitle = String(req.body.notification_title || '').trim()
notificationBody = String(req.body.notification_body || '').trim()
if (!notificationTitle || !notificationBody) {
return res.status(422).json({ error: true, message: 'عنوان و متن نوتیفیکیشن الزامی است' })
}
} else if (LINKED_CONTENT_TYPES[adType]) {
const { bodyKey, kind, message, ownerMessage } = LINKED_CONTENT_TYPES[adType]
const rawId = req.body[bodyKey]
if (!rawId || !mongoose.Types.ObjectId.isValid(rawId)) {
return res.status(422).json({ error: true, message })
}
const resolvedId = await resolveLinkedEntity(kind, rawId, req.user._id)
if (!resolvedId) {
return res.status(403).json({ error: true, message: ownerMessage })
}
if (adType === 'project') contentProjectId = resolvedId
else if (adType === 'product') contentShopProductId = resolvedId
else if (adType === 'shop') contentShopId = resolvedId
else if (adType === 'billboard') contentBillboardId = resolvedId
}
const targeting = normalizeTargeting(req.body)
const price = await resolveAdPrice(adType, {
provinceId: targeting.target_province_id,
gender: targeting.target_gender === 'any' ? null : targeting.target_gender,
expertise: targeting.target_expertise,
subExpertise: targeting.target_sub_expertise
})
if (!price || price <= 0) {
return res.status(422).json({ error: true, message: 'قیمتی برای این سناریوی تبلیغ تعریف نشده است' })
}
const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit(
req.user._id,
price,
useWallet
)
const adPayload = {
user_id: req.user._id,
ad_type: adType,
content_post_id: contentPostId,
content_story_id: contentStoryId,
content_project_id: contentProjectId,
content_shop_product_id: contentShopProductId,
content_shop_id: contentShopId,
content_billboard_id: contentBillboardId,
content_media_path: contentMediaPath,
content_media_type: contentMediaType,
content_overlays: contentOverlays,
notification_title: notificationTitle,
notification_body: notificationBody,
...targeting,
price,
walletAmountUsed: walletAmount
}
if (fullyCovered) {
const ad = await AdModel.create(adPayload)
await debitWalletForPurchase(req.user._id, walletAmount, {
referenceType: 'payment',
referenceId: ad._id,
description: 'ثبت تبلیغ از کیف پول'
})
await activateAd(ad)
return res.status(201).json({ success: true, paidFromWallet: true, adId: ad._id })
}
const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL
if (!callbackBase) {
return res.status(500).json({ error: true, message: 'آدرس بازگشت پرداخت تنظیم نشده است' })
}
const ad = await AdModel.create({ ...adPayload, status: 'pending_payment' })
const response = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/request.json',
{
merchant_id: MERCHANT_ID,
amount: toRial(gatewayAmount),
description: `ثبت تبلیغ (${adType})`,
callback_url: `${callbackBase}/payment/ads/callback?adId=${ad._id}`
},
{ headers: { 'Content-Type': 'application/json' } }
)
const result = response.data?.data
if (result?.code === 100 && result?.authority) {
ad.payment_authority = result.authority
await ad.save()
return res.status(201).json({
success: true,
adId: ad._id,
authority: result.authority,
paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}`
})
}
await AdModel.deleteOne({ _id: ad._id })
console.error('ZarinPal initiate error:', response.data)
return res.status(502).json({ error: true, message: 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.' })
} catch (error) {
console.error('createAd error:', error?.response?.data || error.message)
next(error)
}
}
/** GET /ads/payment/verify — کال‌بک زرین‌پال */
const verifyAdPayment = async (req, res, next) => {
const { adId, Authority, Status } = req.query
const failRedirect = `${process.env.APP_SITE}/settings/ads?payment=failed`
const successRedirect = `${process.env.APP_SITE}/settings/ads?payment=success`
try {
const ad = await AdModel.findById(adId)
if (!ad || ad.status !== 'pending_payment') {
return res.redirect(failRedirect)
}
if (Status !== 'OK') {
ad.status = 'failed'
await ad.save()
return res.redirect(failRedirect)
}
const walletAmount = ad.walletAmountUsed || 0
const gatewayAmount = ad.price - walletAmount
const verify = await axios.post(
'https://api.zarinpal.com/pg/v4/payment/verify.json',
{
merchant_id: MERCHANT_ID,
authority: Authority,
amount: toRial(gatewayAmount)
},
{ headers: { 'Content-Type': 'application/json' } }
)
const data = verify.data?.data
if (data?.code === 100 || data?.code === 101) {
await debitWalletForPurchase(ad.user_id, walletAmount, {
referenceType: 'payment',
referenceId: ad._id,
description: 'ثبت تبلیغ از کیف پول'
})
await activateAd(ad)
return res.redirect(successRedirect)
}
ad.status = 'failed'
await ad.save()
return res.redirect(failRedirect)
} catch (error) {
console.error('verifyAdPayment error:', error?.response?.data || error.message)
return res.redirect(failRedirect)
}
}
/** GET /ads/mine */
const getMyAds = async (req, res, next) => {
try {
const ads = await AdModel.find({ user_id: req.user._id })
.sort({ createdAt: -1 })
.lean()
const now = Date.now()
const withEffectiveStatus = ads.map((ad) => ({
...ad,
effective_status:
ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now
? 'expired'
: ad.status
}))
return res.json({ ads: withEffectiveStatus })
} catch (error) {
next(error)
}
}
/** GET /ads/:id — جزئیات + آنالیتیکس */
const getAdDetail = async (req, res, next) => {
try {
const ad = await AdModel.findById(req.params.id).lean()
if (!ad || String(ad.user_id) !== String(req.user._id)) {
return res.status(404).json({ error: true, message: 'تبلیغ یافت نشد' })
}
const now = Date.now()
const effectiveStatus =
ad.status === 'active' && ad.expires_at && new Date(ad.expires_at).getTime() < now
? 'expired'
: ad.status
let content = null
if (ad.ad_type === 'first_post' && ad.content_post_id) {
content = await PostModel.findById(ad.content_post_id).select('files caption type').lean()
} else if (ad.ad_type === 'first_story') {
// اسنپ‌شاتِ خودِ AdModel — نه لوکاپِ زنده روی StoryModel، چون استوریِ اصلی معمولاً
// ۲۴ساعته منقضی می‌شه ولی این جزئیات باید کاملِ عمرِ تبلیغ در دسترس بمونه
content = { media_path: ad.content_media_path, media_type: ad.content_media_type }
} else if (SPONSORED_TYPES.includes(ad.ad_type)) {
content = await buildAdLinkedContentItem(ad)
}
return res.json({ ad: { ...ad, effective_status: effectiveStatus }, content })
} catch (error) {
next(error)
}
}
/** GET /ads/sponsored?ad_type=project|product|shop|billboard — لیستِ مربوطه (اسپشیالیست‌ها/
* کالاهای فروشگاه/فروشگاه‌ها/بیلبوردها) این رو صدا می‌زنه و اگه چیزی برگشت، اولِ لیستِ خودش
* نشونش می‌ده. برخلافِ first_post/first_story که مستقیم توی fید/استوری تزریق می‌شن، این چهار
* نوع endpoint جدا دارن — چون fetch لیست‌شون توی کنترلرهای پرترافیک/حساسِ جداگانه‌ست و
* دست‌نخورده موندنشون امن‌تره */
const getSponsoredAd = async (req, res, next) => {
try {
const { ad_type: adType } = req.query
if (!SPONSORED_TYPES.includes(adType)) {
return res.status(422).json({ error: true, message: 'نوع تبلیغ نامعتبر است' })
}
const viewer = await UserModel.findById(req.user._id)
.select('country province city gender expertise sub_expertise')
.lean()
const ad = await findActiveAd(adType, { ...viewer, _id: req.user._id })
if (!ad) return res.json({ ad: null })
const item = await buildAdLinkedContentItem(ad)
if (!item) return res.json({ ad: null })
trackAdEvent(ad._id, 'view').catch(() => {})
return res.json({ ad: item })
} catch (error) {
next(error)
}
}
/** POST /ads/:id/track — {type:'view'|'click'} — بیننده صدا می‌زنه، نیازی به مالکیت نیست */
const trackAdEventEndpoint = async (req, res, next) => {
try {
const { type } = req.body
if (!['view', 'click'].includes(type)) {
return res.status(422).json({ error: true, message: 'نوع رویداد نامعتبر است' })
}
await trackAdEvent(req.params.id, type)
return res.json({ success: true })
} catch (error) {
next(error)
}
}
module.exports = {
getAdPricing,
createAd,
verifyAdPayment,
getMyAds,
getAdDetail,
getSponsoredAd,
trackAdEventEndpoint
}