From fcbd34b0c6216bba43fbf8e7dfdafff204111357 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Mon, 28 Sep 2026 23:41:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(discord):=20Kunden-Kontoverkn=C3=BCpfung?= =?UTF-8?q?=20per=20Discord-OAuth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erster Teil des größeren Discord-Ausbaus (Konto verknüpfen, Tickets über Discord sehen/beantworten). Dieser Schritt: Kunden können unter "Mein Konto" ihr Discord-Konto per OAuth verknüpfen/trennen. - Migration 027: discord_settings um client_id/client_secret_enc/ ticket_channel_id erweitert; users um discord_user_id/ discord_username (unique); discord_oauth_states (kurzlebiger CSRF-Schutz für den Redirect-Flow, 10 Min. gültig); neue Tabelle ticket_discord_threads für den nächsten Schritt (Ticket-Threads). - Neue Routen: GET/POST /discord/link, /discord/unlink, GET /discord/oauth/start (leitet zu Discord weiter), GET /discord/oauth/callback (tauscht Code gegen Discord-Nutzer-ID, verknüpft das Konto, verhindert doppelte Verknüpfung). - Einstellungen > Discord: neue Felder für Client ID/Secret, Anzeige der einzutragenden Redirect-URI, aktualisierte Anleitung inkl. OAuth-Einrichtung und Hinweis auf die Message Content Intent (für den nächsten Schritt: Ticket-Threads). Noch offen (nächster Schritt): Ticket-Threads je Ticket, bidirektionale Synchronisierung der Nachrichten. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/modules/discord/index.ts | 74 +++++++++++++++++-- .../app/(app)/einstellungen/discord/page.tsx | 62 +++++++++------- apps/web/src/app/(app)/konto/page.tsx | 27 ++++++- migrations/027_discord_linking.sql | 23 ++++++ 4 files changed, 153 insertions(+), 33 deletions(-) create mode 100644 migrations/027_discord_linking.sql diff --git a/apps/api/src/modules/discord/index.ts b/apps/api/src/modules/discord/index.ts index f48b166..ef123c9 100644 --- a/apps/api/src/modules/discord/index.ts +++ b/apps/api/src/modules/discord/index.ts @@ -2,18 +2,20 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { one, run } from '../../core/db.js'; import { audit } from '../../core/audit.js'; -import { encrypt, decrypt } from '../../core/crypto.js'; -import { clientIp, requirePermission } from '../../core/auth.js'; +import { encrypt, decrypt, randomToken } from '../../core/crypto.js'; +import { clientIp, requireAuth, requirePermission } from '../../core/auth.js'; import { badRequest } from '../../core/errors.js'; -import { rl } from '../../core/config.js'; +import { rl, config } from '../../core/config.js'; import type { KcModule } from '../../core/module.js'; const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs const settingsView = (s: any) => ({ enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id, staffUserIds: String(s.staff_user_ids ?? '').split(',').map((x: string) => x.trim()).filter(Boolean), + clientId: s.client_id, hasClientSecret: !!s.client_secret_enc, ticketChannelId: s.ticket_channel_id, configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at, }); +const redirectUri = () => `${config.baseUrl}/api/discord/oauth/callback`; export const discordModule: KcModule = { name: 'discord', @@ -28,13 +30,16 @@ export const discordModule: KcModule = { const b = z.object({ token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(), + ticketChannelId: z.string().trim().regex(ID).nullable(), staffUserIds: z.array(z.string().trim().regex(ID)).max(50), enabled: z.boolean().default(false), + clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(), }).parse(req.body); - const sets = ['guild_id = ?', 'admin_channel_id = ?', 'staff_user_ids = ?', 'enabled = ?', 'updated_by = ?']; - const params: unknown[] = [b.guildId, b.adminChannelId, b.staffUserIds.join(','), b.enabled ? 1 : 0, a.user.id]; + const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'staff_user_ids = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?']; + const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.staffUserIds.join(','), b.enabled ? 1 : 0, b.clientId, a.user.id]; if (b.token !== undefined) { sets.push('token_enc = ?'); params.push(b.token ? encrypt(JSON.stringify({ token: b.token })) : null); } + if (b.clientSecret !== undefined) { sets.push('client_secret_enc = ?'); params.push(b.clientSecret ? encrypt(JSON.stringify({ secret: b.clientSecret })) : null); } await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params); - await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.update', resourceType: 'discord_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, token: b.token !== undefined ? (b.token ? '***' : null) : undefined } }); + await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.update', resourceType: 'discord_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, token: b.token !== undefined ? (b.token ? '***' : null) : undefined, clientSecret: b.clientSecret !== undefined ? (b.clientSecret ? '***' : null) : undefined } }); return { ok: true }; }); /** Prüft nur den Token gegen die Discord-API (GET /users/@me), ohne den laufenden Bot zu berühren. */ @@ -73,5 +78,62 @@ export const discordModule: KcModule = { await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.settings.test_message', resourceType: 'discord_settings', resourceId: '1', result: ok ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { ok } }); return { ok, error }; }); + + // ---- Konto-Verknüpfung (Mein Konto): jeder angemeldete Benutzer darf sein eigenes Discord-Konto verknüpfen ---- + app.get('/discord/link', async (req) => { + const a = requireAuth(req); + const [u, s] = await Promise.all([ + one('SELECT discord_user_id, discord_username FROM users WHERE id = ?', [a.user.id]), + one('SELECT client_id, client_secret_enc FROM discord_settings WHERE id = 1'), + ]); + return { linked: !!u?.discord_user_id, username: u?.discord_username ?? null, available: !!(s?.client_id && s?.client_secret_enc) }; + }); + app.post('/discord/unlink', async (req) => { + const a = requireAuth(req); + await run('UPDATE users SET discord_user_id = NULL, discord_username = NULL WHERE id = ?', [a.user.id]); + await audit({ actorType: 'user', actorId: a.user.id, action: 'discord.unlink', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) }); + return { ok: true }; + }); + /** Startet den Discord-OAuth-Fluss: legt einen kurzlebigen Zustand an und leitet den Browser zu Discord weiter. */ + app.get('/discord/oauth/start', async (req, reply) => { + const a = requireAuth(req); + const s = await one('SELECT client_id FROM discord_settings WHERE id = 1'); + if (!s?.client_id) throw badRequest('Discord-Anmeldung ist noch nicht eingerichtet.', 'DISCORD_OAUTH_NOT_CONFIGURED'); + const state = randomToken(32); + await run('DELETE FROM discord_oauth_states WHERE user_id = ?', [a.user.id]); // nur ein offener Versuch je Benutzer + await run('INSERT INTO discord_oauth_states (state, user_id) VALUES (?,?)', [state, a.user.id]); + const url = new URL('https://discord.com/api/oauth2/authorize'); + url.searchParams.set('client_id', s.client_id); url.searchParams.set('redirect_uri', redirectUri()); + url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', 'identify'); url.searchParams.set('state', state); url.searchParams.set('prompt', 'consent'); + return reply.redirect(url.toString()); + }); + /** Discord leitet den Browser hierher zurück; tauscht den Code gegen die Discord-Nutzer-ID und verknüpft das Konto. */ + app.get('/discord/oauth/callback', async (req, reply) => { + const q = z.object({ code: z.string().optional(), state: z.string().optional(), error: z.string().optional() }).parse(req.query); + const fail = (msg: string) => reply.redirect(`${config.baseUrl}/konto?discord=error&msg=${encodeURIComponent(msg)}`); + if (q.error || !q.code || !q.state) return fail(q.error === 'access_denied' ? 'Verknüpfung abgebrochen.' : 'Ungültige Antwort von Discord.'); + const st = await one('SELECT user_id FROM discord_oauth_states WHERE state = ? AND created_at > DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE)', [q.state]); + if (!st) return fail('Der Verknüpfungsversuch ist abgelaufen. Bitte erneut starten.'); + await run('DELETE FROM discord_oauth_states WHERE state = ?', [q.state]); + const s = await one('SELECT client_id, client_secret_enc FROM discord_settings WHERE id = 1'); + if (!s?.client_id || !s.client_secret_enc) return fail('Discord-Anmeldung ist nicht eingerichtet.'); + const clientSecret = JSON.parse(decrypt(s.client_secret_enc)).secret as string; + try { + const tokenRes = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: s.client_id, client_secret: clientSecret, grant_type: 'authorization_code', code: q.code, redirect_uri: redirectUri() }), + }); + if (!tokenRes.ok) throw new Error(`Discord antwortete mit Status ${tokenRes.status}`); + const { access_token } = await tokenRes.json() as { access_token: string }; + const meRes = await fetch('https://discord.com/api/v10/users/@me', { headers: { authorization: `Bearer ${access_token}` } }); + if (!meRes.ok) throw new Error(`Discord antwortete mit Status ${meRes.status}`); + const me = await meRes.json() as { id: string; username: string }; + const other = await one('SELECT id FROM users WHERE discord_user_id = ? AND id <> ?', [me.id, st.user_id]); + if (other) return fail('Dieses Discord-Konto ist bereits mit einem anderen Zugang verknüpft.'); + await run('UPDATE users SET discord_user_id = ?, discord_username = ? WHERE id = ?', [me.id, me.username, st.user_id]); + await audit({ actorType: 'user', actorId: st.user_id, action: 'discord.link', resourceType: 'user', resourceId: st.user_id, correlationId: req.correlationId, ip: clientIp(req), after: { discordUsername: me.username } }); + return reply.redirect(`${config.baseUrl}/konto?discord=linked`); + } catch (e) { return fail(String((e as Error).message).slice(0, 200)); } + }); }, }; diff --git a/apps/web/src/app/(app)/einstellungen/discord/page.tsx b/apps/web/src/app/(app)/einstellungen/discord/page.tsx index d28cb5b..49692c8 100644 --- a/apps/web/src/app/(app)/einstellungen/discord/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/discord/page.tsx @@ -4,18 +4,19 @@ import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; import { Alert, Field, fmt } from '@/components/ui'; -interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; staffUserIds: string[]; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string } +interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; staffUserIds: string[]; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string } /** Schritt-für-Schritt-Anleitung, damit auch ohne Discord-Vorwissen ein Bot eingerichtet werden kann. */ -function Guide() { +function Guide({ redirectUri }: { redirectUri: string }) { return (
Anleitung: Discord-Bot einrichten (aufklappen)
  1. Anwendung anlegen: Auf discord.com/developers/applications auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).
  2. -
  3. Bot-Token erzeugen: Im Reiter „Bot“ auf „Reset Token“ klicken und den Token kopieren. Er wird nur dieses eine Mal angezeigt – am besten direkt unten einfügen und speichern. Privilegierte Intents (Message Content etc.) werden nicht benötigt.
  4. -
  5. Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.
  6. -
  7. IDs ermitteln: In Discord unter Einstellungen → Erweitert den „Entwicklermodus“ aktivieren. Danach mit Rechtsklick auf den Server, den gewünschten Kanal und die eigene Person jeweils „ID kopieren“ wählen.
  8. -
  9. Hier eintragen: Token, Server-ID und Kanal-ID (für Systemmeldungen wie Backup-Warnungen oder neue Tickets) unten speichern. Unter „Erlaubte Nutzer-IDs“ die Discord-IDs aller Personen eintragen, die im Server die Befehle /kc-status (Systemzustand) und /kc-kunde (Kundensuche) nutzen dürfen.
  10. +
  11. Bot-Token erzeugen: Im Reiter „Bot“ auf „Reset Token“ klicken und den Token kopieren. Er wird nur dieses eine Mal angezeigt – am besten direkt unten einfügen und speichern. Dort außerdem die Message Content Intent aktivieren (wird benötigt, damit Kundenantworten in Ticket-Threads gelesen werden können).
  12. +
  13. Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.
  14. +
  15. Kunden-Anmeldung (OAuth) einrichten: Im Reiter „OAuth2 → General“ die Client ID und (unter „Reset Secret“) das Client Secret kopieren, unten eintragen. Unter „Redirects“ genau diese Adresse eintragen: {redirectUri}
  16. +
  17. IDs ermitteln: In Discord unter Einstellungen → Erweitert den „Entwicklermodus“ aktivieren. Danach mit Rechtsklick auf den Server, die gewünschten Kanäle und die eigene Person jeweils „ID kopieren“ wählen.
  18. +
  19. Hier eintragen: Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern. Unter „Erlaubte Nutzer-IDs“ die Discord-IDs aller Personen eintragen, die /kc-status und /kc-kunde nutzen dürfen.
); } @@ -32,11 +33,11 @@ export default function DiscordSettings() { e.preventDefault(); if (!w) return; setBusy(true); setMsg(null); const form = e.currentTarget; const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null); - const token = String(f.get('token') ?? ''); + const token = String(f.get('token') ?? ''); const clientSecret = String(f.get('clientSecret') ?? ''); const staffUserIds = String(f.get('staffUserIds') ?? '').split(',').map((x) => x.trim()).filter(Boolean); try { - await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), staffUserIds, enabled: f.get('enabled') === 'on', ...(token ? { token } : {}) }); - setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; void load(); + await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), staffUserIds, enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) }); + setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; (form.elements.namedItem('clientSecret') as HTMLInputElement).value = ''; void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); } } async function testToken() { @@ -50,31 +51,42 @@ export default function DiscordSettings() { catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setMsgBusy(false); } } if (!s) return err ? {err} :

Wird geladen …

; + const redirectUri = typeof window !== 'undefined' ? `${window.location.origin}/api/discord/oauth/callback` : ''; return (<>

Discord

{err && {err}} - -
-

Bot-Verbindung

-

Der Bot meldet Systemereignisse (neue Tickets, Rechnungen, Backup-Probleme) im gewählten Kanal und beantwortet die Befehle /kc-status und /kc-kunde für die unten eingetragenen Personen.

- {!s.configured && Noch kein Token gespeichert.} - {s.configured && !s.enabled && Token gespeichert, aber deaktiviert – der Bot verbindet sich nicht.} - {s.lastError && Letzte Störung: {s.lastError}} - {msg && {msg.t}} -
+ + {msg && {msg.t}} + +
+

Bot-Verbindung

+

Der Bot meldet Systemereignisse (neue Tickets, Rechnungen, Backup-Probleme) im gewählten Kanal und beantwortet die Befehle /kc-status und /kc-kunde für die unten eingetragenen Personen.

+ {!s.configured && Noch kein Token gespeichert.} + {s.configured && !s.enabled && Token gespeichert, aber deaktiviert – der Bot verbindet sich nicht.} + {s.lastError && Letzte Störung: {s.lastError}}
+
- {w &&
- {s.hasToken && } - {s.hasToken && s.adminChannelId && } -
} - -

Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}

-
+

Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}

+
+
+

Kunden-Anmeldung (OAuth)

+

Ermöglicht Kunden, ihr Discord-Konto unter „Mein Konto“ mit ihrem Kundencenter-Zugang zu verknüpfen. Client ID und Secret stehen in derselben Discord-Anwendung wie der Bot, im Reiter „OAuth2 → General“.

+
+ + + +
+
+ {w &&
+ {s.hasToken && } + {s.hasToken && s.adminChannelId && } +
} + ); } diff --git a/apps/web/src/app/(app)/konto/page.tsx b/apps/web/src/app/(app)/konto/page.tsx index 7416f2c..c40d76f 100644 --- a/apps/web/src/app/(app)/konto/page.tsx +++ b/apps/web/src/app/(app)/konto/page.tsx @@ -1,18 +1,32 @@ 'use client'; import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import QRCode from 'qrcode'; import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; import { Alert, Field, fmt } from '@/components/ui'; interface Sess { id: string; ip: string | null; userAgent: string | null; lastSeenAt: string; current: boolean } +interface DiscordLink { linked: boolean; username: string | null; available: boolean } export default function Konto() { - const { me, reload } = useSession(); + const { me, reload } = useSession(); const router = useRouter(); const params = useSearchParams(); const [sessions, setSessions] = useState([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [resetOpen, setResetOpen] = useState(false); const [setup, setSetup] = useState<{ secret: string; url: string; qr: string } | null>(null); const [codes, setCodes] = useState(null); + const [discord, setDiscord] = useState(null); const [discordBusy, setDiscordBusy] = useState(false); const load = useCallback(() => api('GET', '/auth/sessions').then(setSessions).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []); - useEffect(() => { if (me && !me.mfaEnrollRequired) void load(); }, [me, load]); + const loadDiscord = useCallback(() => api('GET', '/discord/link').then(setDiscord).catch(() => undefined), []); + useEffect(() => { if (me && !me.mfaEnrollRequired) { void load(); void loadDiscord(); } }, [me, load, loadDiscord]); + useEffect(() => { + const d = params.get('discord'); + if (d === 'linked') { setMsg({ k: 'ok', t: 'Discord-Konto verknüpft.' }); void loadDiscord(); router.replace('/konto'); } + else if (d === 'error') { setMsg({ k: 'err', t: `Discord-Verknüpfung fehlgeschlagen: ${params.get('msg') ?? 'Unbekannter Fehler'}` }); router.replace('/konto'); } + }, [params, router, loadDiscord]); + async function unlinkDiscord() { + setDiscordBusy(true); + try { await api('POST', '/discord/unlink'); setMsg({ k: 'ok', t: 'Discord-Konto getrennt.' }); void loadDiscord(); } + catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setDiscordBusy(false); } + } if (!me) return null; async function startMfa() { @@ -63,6 +77,15 @@ export default function Konto() { : <>

Schützen Sie Ihr Konto zusätzlich mit einem Code aus einer Authenticator-App.

} {!me.mfaEnrollRequired && (<> +

Discord

+ {discord === null ?

Wird geladen …

: discord.linked ? (<> +

Verknüpft als {discord.username}. Tickets sind bei Bedarf auch über Discord einsehbar.

+ + ) : discord.available ? (<> +

Discord-Konto verknüpfen, um Tickets auch dort zu sehen und zu beantworten.

+ Mit Discord verbinden + ) :

Diese Funktion ist derzeit nicht verfügbar.

} +

Passwort ändern

diff --git a/migrations/027_discord_linking.sql b/migrations/027_discord_linking.sql new file mode 100644 index 0000000..fa9964a --- /dev/null +++ b/migrations/027_discord_linking.sql @@ -0,0 +1,23 @@ +-- Discord-OAuth: Kunden verknüpfen ihr Discord-Konto mit ihrem Kundencenter-Konto (für Ticket-Kanäle je Ticket). +ALTER TABLE discord_settings + ADD COLUMN client_id VARCHAR(32) NULL, + ADD COLUMN client_secret_enc TEXT NULL, + ADD COLUMN ticket_channel_id VARCHAR(32) NULL; + +ALTER TABLE users + ADD COLUMN discord_user_id VARCHAR(32) NULL UNIQUE, + ADD COLUMN discord_username VARCHAR(100) NULL; + +-- Kurzlebiger Zustand für den OAuth-Redirect (CSRF-Schutz), wird beim erfolgreichen Callback gelöscht. +CREATE TABLE discord_oauth_states ( + state CHAR(43) PRIMARY KEY, + user_id CHAR(36) NOT NULL REFERENCES users(id), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Ticket <-> Discord-Thread-Zuordnung. +CREATE TABLE ticket_discord_threads ( + ticket_id CHAR(36) PRIMARY KEY REFERENCES tickets(id), + thread_id VARCHAR(32) NOT NULL UNIQUE, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;