Click Tracking Link
Overview
This endpoint turns a tracking token into a destination URL while recording click statistics in the same pass. Its main use is rich menu buttons: the user taps a button, LIFF opens, the web app calls this endpoint for the URL and forwards the user on — and the system records who tapped which button.
The governing principle is that this endpoint always returns a destinationUrl. Click recording
is a fire-through side effect that swallows every error; nothing can break the redirect except one
case — an unknown token, which returns 404.
Business Flow
GET /api/tracking/redirect/:token (the x-liff-token header is optional)
A token can be resolved along two paths.
The built-in path (no database access at all)
- Decrypt the token with an AES-256-GCM keyring. Its layout is
keyId(1) ‖ nonce(12) ‖ ciphertext ‖ tag(16), base64url-encoded without padding, with keys coming from theCAMPAIGN_LINK_KEYSconfig. - The AAD here is
"tracking-link-v1", deliberately different from the campaign redirect's. A campaign token therefore cannot decrypt as a tracking token even though both use the same key set. - On successful decryption, if the token has not expired (
p.E) andctisrich_menu, a click is written on a best-effort basis (lead_genis not counted, matching legacy parity), andp.Dis returned as the destination. - An empty keyring or a failed decryption fails closed silently and falls through to the legacy path.
The legacy path (reads the table)
SELECT ... FROM tracking_token WHERE token = $1 LIMIT 1. No row returns 404"Tracking token not found".- A click is recorded only if the token has not expired and
statusisactiveandcontent_typeisrich_menu. In every other case the destination is still returned as normal. - Click fields are computed from the row and the jsonb
metadatafollowing the legacy fallback order precisely:richMenuId(from metadata only),richMenuActionIndex(fromrow.action_index, thenmetadata.richMenuActionIndex),richMenuArchiveId(frommetadata.richMenuArchiveId), andtrackingLabel(fromrow.tracking_label, thenmetadata.trackingLabel, then an empty string).
Writing the click (writeClick) — identical on both paths
- Identify the user through
liff.VerifyAndGetLineUser, which auto-provisions a guest so the foreign key is always satisfiable. If there is no token, no configured verifier, a failed verification, or no resulting user, the click is recorded as anonymous with a NULLline_uid, and the log distinguishes the causes ("no token supplied" versus "token supplied but failed verification") so anonymous rows can be diagnosed later. - Insert into
tracking_logwithservice='redirect',action_type='click',type='uri', andcontent_type='rich_menu'. Thetitlecolumn doubles as the trackingLabel because it is NOT NULL.raw_datais jsonb and must be passed as a string, not[]byte: the pool uses the pgx simple query protocol, which text-encodes[]byteas bytea, and the jsonb column rejects it. This was the bug that silently stoppedtracking_logwrites on both paths.
- Upsert
tracking_line_usersto dedupe per user and click point, only when the user is known and arichMenuIdis present. Thetracking_keyformat is{richMenuId}:{archiveId}:{actionIndex}(a missing archiveId becomes 0). This format must match the worker's. The upsert usesON CONFLICT (tracking_key, line_user_id) DO NOTHING. - Set the Redis flag
RICH_MENU_STAT_DIRTY:{id}with a 7200-second TTL so the cron job recomputes the statistics. The legacy guard was a JS truthy check, so arichMenuIdof 0 does not set the flag — unlike the upsert above, which uses a not-null condition.
Key Files & Functions
| Item | Value |
|---|---|
| Route | GET /api/tracking/redirect/:token |
| Register | internal/trackingredirect/register.go → Register(r, deps) |
| Handler | internal/trackingredirect/handler.go → (*Handler).Resolve |
| Service | internal/trackingredirect/service.go → New(...), ResolveAndTrack, recordClick, writeClick, fieldsFromBuiltin, fieldsFromRow; types Destination, clickFields |
| Token ring | internal/trackingtoken/token.go → NewRing(env), (Ring).Decrypt, Payload, aad = "tracking-link-v1" |
| Helpers | internal/trackingredirect/util.go → parseMetadata, jsonNumberToInt64, jsInt64, jsNullInt64, rawMeta, nowFn |
| Cache | internal/cache → Set, used as the DirtyMarker |
Note: this package uses raw SQL with no separate entity or repository layer, deliberately, to mirror
the legacy dataSource.query one for one — the same $N placeholders, the same column set, and the
same tracking_key format.
Connections to Other Services
- Tables
tracking_token,tracking_log,tracking_line_users - Redis via
internal/cachefor theRICH_MENU_STAT_DIRTY:*flags - The
CAMPAIGN_LINK_KEYSconfig, shared with campaign-redirect but namespaced apart by AAD - liff-authentication via
VerifyAndGetLineUser(which auto-provisions a guest) - Tokens are minted by cms-api-go (
internal/core/trackinglink); the statistics are read by the CMS and by a worker-side cron job, both of which must use the sametracking_keyformat - Corresponding client-web feature:
tracking-redirect(the/{hash}/r/{id}route and the logic for avoiding a nested LIFF window)