Diskuze#
Nejmenší modul systému — 12 entit, žádné dynamické parametry, žádné hlídače, žádná
expirace. Kód: app/UI/Api/Discussion/, front app/UI/Front/Discussion/.
Z čeho se modul skládá#
| Skupina | Entity |
|---|---|
| obsah | Message (nested set), MessageAssetRel |
| taxonomie | Category, CategoryText, CategoryPrice (ceník kategorie) |
| interakce | MessageVote, MessageReport |
| placení | Tariff, TariffText, TariffPrice, TariffQuickPrice, PaymentItem |
Zakázaná slova modul vlastní nemá. Čte jeden seznam pro celý web, systémovou tabulku
cms_system_blacklisted_words (Systém → Zakázaná slova), přes Api System\BlacklistedWordManager.
MessageManager::beforeSave() odmítne předmět nebo text návštěvníka se zakázaným slovem jazyka
stránky (textRefusal()), a totéž dělá CommentManager u komentářů vláken typu, který to
vyžaduje (ThreadType::checksBlacklistedWords(), dnes jen DiscussionMessage). Administrátor,
cron a interní úlohy se nekontrolují. Odmítnutí nese errorCode blacklistedWord
(BlacklistedWordRefusal) a front ukáže hlášku front.general.blacklistedWord.
Modulové tabulky zakázaných slov už neexistují
Dřívější cms_mod_discussion_blacklisted_words (a kopie v inzerci a katalogu firem)
migrace odstranila spolu s entitami, managery a repozitáři. Nový kód sahá jen na
systémový BlacklistedWordManager (findActiveValues(?languageId), textRefusal()).
Vlákno a příspěvek jsou jedna entita#
Message je nested set (lft, rgt, level). Vlákno je kořenový uzel, odpověď
je jeho potomek — mezi „téma“ a „příspěvek“ tedy není žádný typový rozdíl.
Přesun příspěvku není nijak omezený
Administrátor umí přetáhnout kterýkoli uzel kamkoli a systém nekontroluje, jestli to dává smysl. Přetažením odpovědi pod jiné téma se diskuze rozejde a nedá se to vrátit tlačítkem zpět.
Smazání uzlu smaže celý podstrom
Smazáním úvodního příspěvku zmizí celé vlákno včetně všech odpovědí. V seznamu to vypadá jako smazání jednoho řádku.
Text příspěvku se čistí na serveru#
Pisatel píše v osekaném editoru (addSimpleEditor() v MessageFormComponent) a detail
příspěvku i výpis ve stylu 3 vypisují text přes |noescape. Proto ho
MessageManager::beforeSave() při každém uložení prožene přes EditorHtmlSanitizer::purify()
— na webu, v administraci i z externího API. Povolené značky jsou tytéž jako u parametrů
inzerátů a v klientském editoru (EditorHtmlSanitizer::ALLOWED_HTML).
subject se nečistí: je to prostý text a šablony ho vždy escapují. Purifikace by jen
pokazila legitimní text se znakem <.
Sanitizace patří do manageru, ne do formuláře
Whitelist editoru v rich-editor.js drží jen prohlížeč. Požadavek mimo formulář ho
obejde a bez kontroly v beforeSave() by vznikl uložený XSS. Podrobně
Formuláře.
Co modul nemá#
Užitečnější než výčet funkcí je vědět, co tu není — ušetří to hledání neexistujícího nastavení:
| Není | Čím se to obchází |
|---|---|
| uzamčení vlákna | vypnout „povolit komentáře“, nebo příspěvek deaktivovat |
| placený přístup ke čtení kategorie | platí se jen založení tématu v placené kategorii a tarify příspěvku |
| práva podle kategorie | jen globální přepínače „jen pro přihlášené“ |
Kategorie má příznak active. Vypnutá kategorie, nebo kategorie pod vypnutým předkem, vrátí
na webu 404 (CategoryTrait::isCategoryBranchActive()).
Hodnocení palcem#
Hlasy jsou MessageVote s hodnotou 1 = palec nahoru, 0 = palec dolů. Počítadla na
příspěvku (votesUp, votesDown) přepočítává Api\MessageVoteManager z řádků hlasů
(cms_mod_discussion_message_votes) jedním UPDATE v transakci uložení hlasu — po uložení,
smazání, hromadném deleteAll() i po updateColumns na vote/messageId. Stejně jako u
hodnocení (RatingVoteManager) píše počítadlo cesta hlasu sama; tabulka hlasů je zdroj pravdy.
Počítadla hlasů nepatří do registerEvent
Dřív front po uložení hlasu volal zvlášť :Api:Discussion:Message:registerEvent s
votesUp/votesDown. Ten endpoint mají povolený i hosté a obchází
MessageVoteManager::beforeSave(), takže skript mohl počítadlo zvedat bez hlasu, kolikrát
chtěl, a smazání hlasu ho nesnížilo. Allowlist registerEvent má teď jen čítače zobrazení.
Rozjeté počítadlo se srovná prvním hlasem
Přepočet je absolutní, takže první nový hlas srovná počítadlo se skutečnými hlasy (na DEV měl příspěvek 21 tři hlasy dolů a počítadlo 0).
Na jednu zprávu hlasuje každý jednou. Hlídá to MessageVoteManager::beforeSave() (jen při
vložení, přes hasVoted()):
| Hlasující | Podle čeho se pozná | Co to drží v databázi |
|---|---|---|
| přihlášený | účet (userId), IP se ignoruje |
unikátní index uq_message_user (zpráva × uživatel) |
| nepřihlášený | jen IP (insertIp) |
nic — kontrola je jen v manageru |
Po hlasu zvedni i zobrazenou hodnotu
votesUp a votesDown jsou v MessageManager::$nonInvalidatingColumns, takže zápis
query cache nezneplatní (viz Cache). Entita, ze které se řádek
překreslí, má proto hodnotu o jedna nižší a hlasující by svůj hlas neviděl.
VoteTrait::bumpDisplayedVotes() po úspěšném zápisu zvedne číslo v paměti, bez dalšího
čtení z DB, a redrawControl() ho pošle do snippetu. Při DB_DUPLICATE_ENTRY se
nevolá.
Druhý hlas vrátí DB_DUPLICATE_ENTRY; front podle kódu počítadlo nezvýší a ukáže hlášku, že už hlasoval. Na externí cestě
dosadí server hlasujícího z JWT a IP z požadavku, jinak by deduplikace neměla podle čeho
porovnávat.
Kontrolu v manageru nenahrazuj indexem
Index nepřihlášené nechytí (řádek s NULL uživatelem nikdy nekoliduje) a index nad IP by
srazil dva různé lidi za jednou firemní sítí. Přihlášeného, kterého zachytí až index,
ohlásí uložení porušením unikátnosti, a to zavře EntityManager do konce požadavku.
Hlášení příspěvků#
Návštěvník příspěvek nahlásí (MessageInfoLine::handleReport()); vznikne MessageReport
(uživatel, IP, čas, solved = false). Jeden nahlašovatel má u příspěvku nejvýš jedno nevyřešené
nahlášení (přihlášený podle účtu, host podle IP); další vrátí DB_DUPLICATE_ENTRY a front ho
ukáže jako úspěch. Po vyřešení smí nahlásit znovu.
| Pravidlo | Provedení |
|---|---|
| zápis jen z AJAXu | #[Requires(ajax: true)] — holý GET (robot) je 403 |
| ID z adresy se porovná | hlášení se založí jen pro zprávu této komponenty; podvržené ID nic nezaloží |
příznak Message::$reported je odvozený |
příspěvek je „nahlášený", dokud má aspoň jedno nevyřešené nahlášení |
externí :Api:Discussion:MessageReport:save |
cílový příspěvek musí existovat (jinak ITEM_NOT_FOUND) a přihlášený čerpá kvótu zakládání; front kontrolu ID tahle cesta nevidí |
Příznak dorovnává sdílená traita ReportedFlagSyncTrait (syncReportedFlags()), kterou
používá Api\MessageReportManager i AdvertReportManager inzerce a katalogu firem. Manager jen
řekne, co je cíl a jak se příznak čte a zapisuje (reportTargetField(), reportTargetIdOf(),
reportTargetIdsOf(), targetHasUnsolvedReports(), writeTargetReported()). Přepočet běží po
každé změně: uložení, updateColumns na solved (vyřešit / znovu otevřít) nebo na cílový
sloupec messageId (přesun hlášení; dorovná starý i nový příspěvek), smazání i hromadné
deleteAll() (to háčky mazání obchází, proto si cíle zjistí předem). Diskuze počítá nevyřešená
hlášení přímo DQL dotazem, ne přes findAll, aby výsledek nebyl ze zastaralé query cache.
Chyba přepočtu jde do logu, ne uživateli. Administrace na příznak nesahá.
Příznak reported se ručně nenastavuje
updateColumns($id, ['reported' => …]) na příspěvku je jen pro MessageReportManager.
Ruční zápis se rozejde s přehledem nahlášení.
Administrace nahlášení: Admin\Discussion\Presenters\MessageReportPresenter
(/admin/diskuze-nahlasene-prispevky/prehled), SQL sql/2026-10-02_07_admin_discussion_message_reports.sql
(admin stránka, klíče :Admin:Discussion:MessageReport:{show,edit,del}, položka v menu pod
Diskuzí). Výpis, vyřešení a znovuotevření dává sdílená
ReportModerationPresenterTrait (stejná pro AdvertReportPresenter inzerce a katalogu firem);
presenter jen pojmenuje cíl a oprávnění. Signály solve! a reopen! (handleSolve(),
handleReopen(), jen POST) mají serverovou bránu checkPrivilege(…:edit) — skryté tlačítko
není ochrana. Výchozí pohled je filtr solved = 0; řádky dekoruje decorateReportRows() dvěma
dotazy (cíle a nahlašovatelé), bez N+1.
Provozní návod: Nahlášené příspěvky.
Platby#
Platí se dvě věci: založení tématu v placené kategorii a tarify příspěvku. Callback platby
je DISCUSSION_PAYMENT (PaymentCallbackRegistry), SMS služby DISK
(DiscussionExpressServiceProvider).
Placené kategorie#
Kategorie diskuze má ceník jako kategorie inzerce: CategoryPrice podle platebního
regionu, brány a měny, druh plnění, příznak „Zdarma“ (for_free) a volbu „Nastavit ceny
i podkategoriím“. Entita implementuje PricedCategoryInterface a propsání ceníku do podstromu
dělá sdílený CategoryPriceSyncTrait (stejný jako v inzerci a katalogu firem). Externí Api
for_free nepřepíše (#[ExternalReadonly]).
| Krok | Co se stane |
|---|---|
| vložení tématu | MessageManager zapíše vazby na kategorie s vlastním paid (zdarma = 1, placená = 0) a téma dostane messages.paid: zaplacené je, když je aspoň jedna kategorie zdarma; jinak se uloží nezaplacené |
| odpověď, téma bez kategorie | vždy zaplacené |
| úprava tématu | ponechaná kategorie si paid drží, nová dostane paid podle for_free |
| platba | platební stránka nabídne položku (PaymentItem s categoryId) za každou placenou, dosud nezaplacenou kategorii tématu |
vypořádání DISCUSSION_PAYMENT |
PaymentItemManager po aktivaci položek zavolá MessageManager::markCategoriesPaid(): označí zaplacené vazby i téma, připíše kredity za obsah a přeindexuje |
Nezaplacené téma je mimo výpisy, TOP, sitemapu i kredity za obsah (paid = 1 patří do podmínky
„zveřejněno“ vedle approved_at a confirmed_at). Autor ho vidí v detailu s upozorněním
a tlačítkem Zaplatit a v „Mých příspěvcích“ s odznakem vedoucím na platbu.
Tarify příspěvku#
| Tarif | Účinek | Odkud se ID bere |
|---|---|---|
| topování | plní toped_until; výpis řadí topedUntil > NOW() dopředu a odznak TOP čte Message::isToped() |
discussionToppingTariffId (výchozí 1) v config/Shared/bazaar.neon → AdvertToppingConfig |
| zvýraznění | aktivní platná položka dá kartě ve výpisu třídu highlighted |
discussionHighlightTariffId (výchozí 2) → AdvertHighlightConfig |
| Topbox | příspěvek s aktivní platnou položkou tarifu se ukáže v bočním widgetu „TOP“ (TopMessageList, v aside-left.latte všech šablon) |
id tarifu předává šablona: {control frontDiscussionTopMessageList [3], 'Style_2', 10} |
| zobrazení odkazů | webové adresy v textu jsou na detailu klikací odkazy (TextLinkifier, filtr \|linkify, rel="ugc nofollow"); bez tarifu zůstanou prostým textem |
discussionLinksTariffId (výchozí 4) → AdvertLinksConfig |
Odznak TOP čte toped_until, ne platební položky
Výpis načítá jen položky tarifu zvýraznění, takže dřívější test na tarif 1 v šablonách
nebyl nikdy pravdivý a odznak se neukázal. Teď ho řídí Message::isToped() — stejná
hodnota jako řazení.
Topbox a odkazy mají i inzerce a katalog firem
Topbox je tam TopAdvertList s tarifem 3, odkazy tarif 4 (bazaarLinksTariffId,
comcatLinksTariffId). V inzerci a katalogu adresa bez tarifu navíc zmizí za [URL]
(filtr parametru v režimu „při zobrazení“), viz Bazaar.
Položka platby v administraci musí mít příspěvek (form.messages.missingMessage); sloupec je
v DB nullable, takže stráž je ve formuláři. Hromadné deleteAll() položek přepočítá
toped_until stejně jako delete().
Která cesta přepočítá toped_until jen nahoru a která absolutně (záložka Platby v editaci
příspěvku posílá meta['recalcTopedUntil']), ukazuje tabulka v
Inzerci; diskuze ji má stejnou, jen nad příspěvky.
Smazání aktivní platební položky zvýraznění zkrátí
Prodloužení z platby jde jen nahoru (je monotonní), ruční zásah v administraci umí obojí — a smazání položky se chová jako ruční zkrácení.
Nastavení#
Modul má v tabulce nastavení index 8. Formulář dnes zapisuje:
| Klíč | Typ |
|---|---|
message.perPage, comment.perPage, maxImageSize |
číslo |
message.approval.auto, message.confirmation.required.logged, message.confirmation.required.guest, message.onlyForLoggedIn, comment.onlyForLoggedIn, vote.onlyForLoggedIn |
přepínač |
Schválení a potvrzení příspěvku#
Příspěvek má dvě nezávislé podmínky zveřejnění, obě se vyhodnocují při vložení (vlákno i odpověď, z webu, z API i z administrace):
| Podmínka | Sloupec | Co ji řídí |
|---|---|---|
| schválení správcem | approved_at |
message.approval.auto; správce schvaluje přepínačem Schváleno |
| potvrzení autorem odkazem z e-mailu | confirmed_at (+ otisk klíče v activation_key) |
message.confirmation.required.logged a .guest — podle toho, jestli příspěvek má vlastníka (userId) |
Nevyžaduje-li se potvrzení, příspěvek dostane confirmed_at hned. Vyžaduje-li se,
dostane klíč a autorovi odejde e-mail s odkazem; chybí-li e-mail, potvrdí se hned (odkaz
by neměl kam jít). Veřejné čtecí cesty (výpis, hledání, oblíbené, sitemap, expresní
SMS platba) vždy vyžadují approved_at i confirmed_at; detail čekajícího příspěvku
vidí jen přihlášený autor, s hláškou, bez komentářů a doporučení. „Moje příspěvky“
čekající nefiltrují a označují je štítkem. Přepnutí nastavení už vložené příspěvky nemění.
Rozměry obrázků a vodoznak v nastavení nejsou — nevracejte je
Varianty se čtou z DB (použití discussion-messages a discussion-categories, viz
Varianty obrázků), vodoznak je globální watermarkPath;
přiřazení se edituje v části Obrázky pod formulářem nastavení. Staré klíče se smazaly
2026-08-13; kdyby je někdo do formuláře vrátil, první uložení je založí zpátky,
protože saveSettingForm() zapisuje všechny vyjmenované klíče.
Obrázky u příspěvku#
Uložení nahradí celý seznam obrázků
Nenaběhne-li nahrávací pole, přílohy zmizí — tatáž mechanika jako u každé jiné kolekce. Podrobně Formuláře, oddíl o nahrávání souborů.
Smazání vlákna (deleteNode(), i hromadné deleteAll(), které maže zprávu po zprávě přes
deleteNode()) smaže s ním vlákna komentářů a hodnocení, obrázky i jejich soubory a dokument v ES.
Hlavní obrázek (Message::$asset) se odvozuje z vazeb obrázků. Na externím
:Api:Discussion:Message:save ho vlastník nenastaví (#[FieldAccess] s Privileged na
Api entitě, stejně jako Advert::$asset), jinak by mohl příspěvku podstrčit cizí obrázek.
Správa příspěvku hostem#
Stejný mechanismus jako u inzerátů (sdílený ManageKeyTrait nad ManageKeyAwareInterface):
- Příspěvek bez účtu dostane při vložení náhodné tajemství do sloupce
password(hodnota z dat se ignoruje); nikdo ho nezná, váže se na něj odkaz i editační oprávnění. - E-maily po vložení (
DS_MESSAGE_INSERT,DS_MESSAGE_ACTIVATION_REQUEST) nesou odkazdiskuze-editace/<id>?manageKey=…. V DB je jen otisk (manage_key_hash) a platnost (manage_key_expires, 7 dní); použitím se klíč nespotřebuje. :Api:Discussion:Message:redeemManageKeyklíč ověří a vydá oprávnění s rozsahemdiscussion.message.edit(CapabilityTokenIssuer) — editační relace na 2 hodiny, přesměrování na editaci bez klíče v adrese.- Vlastníka nese dál jen
userId; ApiMessagePresenterpřijme vlastníka podle identity, nebo podle oprávnění s otiskem tajemství z DB. Změna tajemství odkaz i relaci zneplatní. - Migrace
2026-10-27_09_discussion_message_manage_link.sql(sloupce, RBAC klíč, odstavec{if $manageLink}v šablonách e-mailů).
Naplánované úlohy#
| Endpoint | Co dělá | Doporučený interval |
|---|---|---|
/cron/discussion/elastic/sync |
průběžná synchronizace indexu | à 10 minut |
/cron/discussion/elastic/reindex |
úplný reindex | denně v noci |
/cron/discussion/sitemap/{message,category} |
sitemapy | denně v noci |
Kam sáhnout#
| Chci | Kde |
|---|---|
| logiku příspěvků a stromu | app/UI/Api/Discussion/Models/Managers/MessageManager.php |
| hlasování | app/UI/Front/Discussion/Components/CommentListRowComponent/Traits/VoteTrait.php |
| přírůstkový zápis počítadel | app/Core/Traits/Api/Models/Managers/RegisterEventTrait.php |
| nastavení modulu | app/UI/Admin/Discussion/Presenters/SettingPresenter.php |
| cron úlohy | app/UI/Cron/Discussion/Presenters/ |
Navazující kapitoly: Blog · Katalog firem · Elasticsearch · Formuláře