水印的实现方式逻辑

在数字化时代,企业核心数据(如财务报表、客户信息、研发图纸等)的外发与共享日益频繁,数据泄露风险随之剧增。一旦发生数据泄露或篡改事件,企业往往面临"不知道是谁泄露的、不知道从哪个渠道泄露的"尴尬境地。

一条总原则:水印在页面上都是"渲染"出来的。静态的普通可见水印前端 canvas 自己画就行(第一节);需要和责任人绑定、需要防伪的水印,则由接口生成一张水印图,前端把它铺到内容容器上渲染------前端全程不上传原图、不参与任何水印算法。只有溯源环节,才需要把疑似泄露的文件交给接口去提取。


一、可见水印:纯前端实现即可的逻辑

这一类完全不需要接口:canvas 画一张平铺图 + 定位遮罩,浏览器自己就能渲染与导出。

实现逻辑

  1. 用 canvas 画一个只包含一行文字的「平铺单元」,导出 dataURL;
  2. 把这个 dataURL 作为 background-image 铺满一个 position: absolute; inset: 0; pointer-events: none 的遮罩层;
  3. 用 MutationObserver 监听遮罩被删除 / 被改样式,发现异常就重建(自愈);
  4. 导出时,在 canvas 上按同样参数把水印烧进图片像素,再 toBlob 下载。

前端实现

vue 复制代码
<!-- components/AppWatermark.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

interface Props {
  text: string
  fontSize?: number
  color?: string
  opacity?: number
  rotate?: number
  gapX?: number
  gapY?: number
}

const props = withDefaults(defineProps<Props>(), {
  fontSize: 14,
  color: '#000000',
  opacity: 0.12,
  rotate: -22,
  gapX: 180,
  gapY: 120
})

const rootRef = ref<HTMLElement | null>(null)
let layer: HTMLDivElement | null = null
let observer: MutationObserver | null = null

/** 画一个平铺单元并转成 dataURL */
function drawTile() {
  const dpr = window.devicePixelRatio || 1
  const canvas = document.createElement('canvas')
  canvas.width = props.gapX * dpr
  canvas.height = props.gapY * dpr

  const ctx = canvas.getContext('2d')!
  ctx.scale(dpr, dpr)
  ctx.translate(props.gapX / 2, props.gapY / 2)
  ctx.rotate((props.rotate * Math.PI) / 180)

  ctx.font = `${props.fontSize}px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif`
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.globalAlpha = props.opacity
  ctx.fillStyle = props.color
  ctx.fillText(props.text, 0, 0)

  return canvas.toDataURL('image/png')
}

/** 生成(或重建)水印遮罩层 */
function paint() {
  const root = rootRef.value
  if (!root) return

  // 容器需要定位上下文,否则绝对定位会飘到页面根节点
  if (getComputedStyle(root).position === 'static') {
    root.style.position = 'relative'
  }

  layer = document.createElement('div')
  Object.assign(layer.style, {
    position: 'absolute',
    inset: '0',
    zIndex: '9999',
    pointerEvents: 'none',
    userSelect: 'none',
    backgroundImage: `url(${drawTile()})`,
    backgroundRepeat: 'repeat',
    backgroundSize: `${props.gapX}px ${props.gapY}px`
  })
  root.appendChild(layer)
}

/** 防删除:自己改 DOM 前先 disconnect,改完再 observe,避免回调死循环 */
function guard() {
  const root = rootRef.value
  if (!root) return

  observer = new MutationObserver(() => {
    const broken =
      !layer ||
      !layer.isConnected ||
      layer.style.pointerEvents !== 'none' ||
      !layer.style.backgroundImage

    if (!broken) return

    observer?.disconnect()
    layer?.remove()
    paint()
    guard()
  })

  observer.observe(root, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['style', 'class']
  })
}

function rebuild() {
  observer?.disconnect()
  layer?.remove()
  paint()
  guard()
}

onMounted(rebuild)
onBeforeUnmount(() => {
  observer?.disconnect()
  layer?.remove()
  layer = null
  observer = null
})

watch(() => [props.text, props.fontSize, props.gapX, props.gapY, props.opacity], rebuild)
</script>

<template>
  <div ref="rootRef" class="app-watermark">
    <slot />
  </div>
</template>

用法:<AppWatermark text="内部资料 张三 001"><router-view /></AppWatermark> 如果水印文案要跟登录态实时绑定、不想落到前端源码里,就换成第二节的「接口下发水印图」。

前端导出(纯前端,同样不需要接口)

ts 复制代码
// utils/exportWatermarkImage.ts
const loadImage = (src: string) =>
  new Promise<HTMLImageElement>((resolve, reject) => {
    const img = new Image()
    img.onload = () => resolve(img)
    img.onerror = () => reject(new Error('图片加载失败'))
    img.src = src
  })

/** 把水印直接烧进图片像素,导出 PNG */
export async function exportWithWatermark(
  file: File,
  options: { text: string; fontSize?: number; opacity?: number; rotate?: number; gapX?: number; gapY?: number }
) {
  const { text, fontSize = 20, opacity = 0.18, rotate = -22, gapX = 220, gapY = 150 } = options
  const url = URL.createObjectURL(file)

  try {
    const img = await loadImage(url)
    const canvas = document.createElement('canvas')
    canvas.width = img.naturalWidth
    canvas.height = img.naturalHeight

    const ctx = canvas.getContext('2d')!
    ctx.drawImage(img, 0, 0)

    // 旋转后要按对角线铺,否则四个角会露白
    const half = Math.ceil(Math.hypot(canvas.width, canvas.height) / 2)
    ctx.save()
    ctx.translate(canvas.width / 2, canvas.height / 2)
    ctx.rotate((rotate * Math.PI) / 180)
    ctx.globalAlpha = opacity
    ctx.fillStyle = '#000'
    ctx.font = `${fontSize}px -apple-system, "PingFang SC", sans-serif`
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'

    for (let y = -half; y < half; y += gapY) {
      for (let x = -half; x < half; x += gapX) {
        ctx.fillText(text, x, y)
      }
    }
    ctx.restore()

    const blob = await new Promise<Blob>((resolve, reject) =>
      canvas.toBlob(b => (b ? resolve(b) : reject(new Error('导出失败'))), 'image/png')
    )
    const link = document.createElement('a')
    link.href = URL.createObjectURL(blob)
    link.download = 'export.png'
    link.click()
    URL.revokeObjectURL(link.href)
  } finally {
    URL.revokeObjectURL(url)
  }
}

注意事项

  • 水印层必须 pointer-events: none,否则整页都点不动;
  • MutationObserver 只是提高门槛,禁 JS / 直接读接口数据依然能绕过,可见水印的定位是威慑 + 追责心理成本;
  • 大图导出时循环别按像素走,按「对角线的 1/2 ÷ 间距」计算,否则会卡死主线程。

二、隐形水印:接口下发水印图,前端只渲染

实现逻辑

  1. 前端进入敏感页面时,用当前登录态(或路由参数里的 taskId)请求 GET /api/watermark/image/tile;
  2. 后端把「责任人 / 日期 / 任务号」画成一张透明底的斜向文字平铺图,同时把任务号写进图片像素的最低有效位(防伪),以 PNG 返回;
  3. 前端拿到 Blob → URL.createObjectURL → 直接当 background-image 铺在内容容器上,background-size 与后端画布尺寸对齐;
  4. 页面上的表格、报表等数据同样来自接口,前端只做渲染,渲染层与水印层彻底分离;
  5. 导出时前端把「内容 + 水印层」一起截图成 PNG 下载(或交给后端出成品)。

全程没有任何「上传原图」的动作:水印图是接口产的,前端只是把它显示出来。

服务端(FastAPI)

python 复制代码
# server/routers/watermark.py
import io

import numpy as np
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from PIL import Image, ImageDraw, ImageFont

router = APIRouter()

TILE_W, TILE_H = 300, 200                    # 与前端 background-size 保持一致
FONT_PATH = "fonts/NotoSansSC-Regular.ttf"   # 中文必须显式指定字体文件


def _draw_tile(text: str, font_size: int = 16, alpha: int = 24, rotate: int = -22) -> Image.Image:
    """画一张透明底的斜向文字水印单元"""
    layer = Image.new("RGBA", (TILE_W, TILE_H), (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)

    try:
        font = ImageFont.truetype(FONT_PATH, font_size)
    except OSError:
        font = ImageFont.load_default()

    left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
    draw.text(
        ((TILE_W - (right - left)) / 2, (TILE_H - (bottom - top)) / 2),
        text,
        font=font,
        fill=(0, 0, 0, alpha),
    )
    return layer.rotate(rotate, resample=Image.BICUBIC)


def _embed_task_id(img: Image.Image, secret: bytes) -> Image.Image:
    """把任务号写进 RGB 通道最低位(无损 PNG 才有效)"""
    bits = np.unpackbits(np.frombuffer(secret, dtype=np.uint8))
    arr = np.array(img.convert("RGBA"), dtype=np.uint8)
    flat = arr.reshape(-1)                       # RGBA 交错展平
    idx = (np.arange(bits.size) // 3) * 4 + (np.arange(bits.size) % 3)  # 跳过 alpha
    flat[idx] = (flat[idx] & 0xFE) | bits
    return Image.fromarray(flat.reshape(arr.shape))


def _read_task_id(img: Image.Image) -> int:
    arr = np.array(img.convert("RGBA"), dtype=np.uint8).reshape(-1)
    idx = (np.arange(32) // 3) * 4 + (np.arange(32) % 3)
    return int.from_bytes(np.packbits(arr[idx] & 0x01).tobytes(), "big")


@router.get("/image/tile", summary="下发水印背景图,前端直接渲染")
def watermark_tile(
    task_id: int = Query(..., ge=1, description="水印任务号"),
    text: str = Query("", max_length=40, description="水印文案,留空按任务号生成"),
    font_size: int = Query(16, ge=8, le=40),
    alpha: int = Query(24, ge=8, le=80, description="文字透明度 0-255"),
):
    tile = _draw_tile(text or f"内部资料 T{task_id:06d}", font_size=font_size, alpha=alpha)
    tile = _embed_task_id(tile, task_id.to_bytes(4, "big"))

    buf = io.BytesIO()
    tile.save(buf, format="PNG")
    return Response(
        content=buf.getvalue(),
        media_type="image/png",
        headers={
            "Cache-Control": "no-store",          # 水印图每次按人下发,别让 CDN 缓存串号
            "X-Task-Id": str(task_id),
        },
    )


@router.post("/image/extract", summary="从疑似泄露图片中提取任务号(溯源用)")
async def extract_image(file: UploadFile = File(...)):
    try:
        img = Image.open(io.BytesIO(await file.read()))
    except Exception as exc:
        raise HTTPException(status_code=400, detail="图片无法解析") from exc

    task_id = _read_task_id(img)
    if task_id == 0:
        raise HTTPException(status_code=404, detail="未检测到水印")
    return {"task_id": task_id}

需要抗二次压缩、抗缩放的强鲁棒能力时,把 _embed_task_id 换成 DWT-DCT(如 invisible-watermark 的 dwtDct,单次上限 32 bit,正好放 4 字节任务号),接口形态不变。

前端(Vue 3):拿到图就铺,不做任何计算

vue 复制代码
<!-- components/WatermarkLayer.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

const props = defineProps<{ taskId: number; text?: string }>()

const tileUrl = ref('')

onMounted(async () => {
  const query = new URLSearchParams({ task_id: String(props.taskId) })
  if (props.text) query.set('text', props.text)

  const res = await fetch(`/api/watermark/image/tile?${query}`, { credentials: 'include' })
  if (!res.ok) return

  // 接口返回的就是成品水印图,直接转成可用的 URL
  tileUrl.value = URL.createObjectURL(await res.blob())
})

onBeforeUnmount(() => tileUrl.value && URL.revokeObjectURL(tileUrl.value))
</script>

<template>
  <div class="wm-wrap">
    <slot />
    <div
      v-if="tileUrl"
      class="wm-layer"
      :style="{ backgroundImage: `url(${tileUrl})` }"
    />
  </div>
</template>

<style scoped>
.wm-wrap {
  position: relative;
}

.wm-layer {
  position: absolute;
  inset: 0;
  z-index: 9999;
  pointer-events: none;
  user-select: none;
  background-repeat: repeat;
  background-size: 300px 200px; /* 必须等于后端 TILE_W/TILE_H */
}
</style>

页面里直接包住内容即可,数据和渲染都跟水印无关:

vue 复制代码
<template>
  <WatermarkLayer :task-id="2" text="Bob 2026-11-05">
    <ReportTable :rows="rows" />
  </WatermarkLayer>
</template>

导出(前端把内容和水印层一起截下来):

ts 复制代码
// utils/exportPanel.ts
import html2canvas from 'html2canvas' // npm i html2canvas

export async function exportPanel(el: HTMLElement, filename = 'export.png') {
  const canvas = await html2canvas(el, {
    backgroundColor: '#fff',
    scale: window.devicePixelRatio,
    useCORS: true
  })

  const link = document.createElement('a')
  link.href = canvas.toDataURL('image/png')
  link.download = filename
  link.click()
}

注意事项

  • 水印图的 Cache-Control 必须是 no-store,否则不同用户会看到同一个任务号的水印;
  • background-size 写错会导致平铺错位、出现明显接缝,前后端尺寸要作为常量约定;
  • html2canvas 导出的是看得见的那层,像素级 LSB 已在合成中丢失,所以要求"导出仍可溯源"时,让后端出成品文件,前端只负责下载;
  • 水印图建议按登录态生成,不要由前端传 user 之类的参数,否则改个 query 就能伪造责任人。

三、数据水印:零宽字符(接口返回原文 + 水印文本)

实现逻辑

用不可见字符表示二进制:U+200B(零宽空格)= 0,U+200C(零宽非连接符)= 1。接口一次性返回两份文本------text 给前端渲染,watermarked 带给水印,前端复制或导出时用后者,页面视觉完全不变。

服务端(FastAPI)

python 复制代码
# server/routers/watermark.py(续)
import random
from datetime import datetime
from typing import Any

from fastapi import Query
from pydantic import BaseModel, Field

ZERO_WIDTH_0 = "\u200b"  # 0
ZERO_WIDTH_1 = "\u200c"  # 1
GUARD = "\u200d"         # 首尾标记,便于定位水印区间

# 演示用内存表,生产环境替换为数据库
WATERMARK_TASKS: dict[int, dict[str, Any]] = {
    1: {"user": "Alice", "channel": "Email", "time": datetime(2026, 10, 1, 10, 0)},
    2: {"user": "Bob", "channel": "USB", "time": datetime(2026, 10, 2, 14, 30)},
}


class TextContentOut(BaseModel):
    text: str        # 原文,前端直接渲染
    watermarked: str # 带零宽水印的版本,复制/导出时用
    payload: str


class TextExtractIn(BaseModel):
    text: str


class TextExtractOut(BaseModel):
    found: bool
    payload: str = ""


def _encode_text(text: str, payload: str) -> str:
    bits = "".join(f"{byte:08b}" for byte in payload.encode("utf-8"))
    hidden = bits.replace("0", ZERO_WIDTH_0).replace("1", ZERO_WIDTH_1)
    return f"{text}{GUARD}{hidden}{GUARD}"


@router.get("/text/content", response_model=TextContentOut, summary="返回原文与水印文本")
def text_content(
    task_id: int = Query(..., ge=1),
    text: str = Query("本季度财务数据仅供内部使用,请勿外传。", max_length=2000),
):
    task = WATERMARK_TASKS.get(task_id)
    if not task:
        raise HTTPException(status_code=404, detail="任务不存在")

    payload = f"{task['user']}/{task['time']:%Y-%m-%d}/Task_{task_id:03d}"
    return TextContentOut(text=text, watermarked=_encode_text(text, payload), payload=payload)


@router.post("/text/extract", response_model=TextExtractOut, summary="从文本还原水印")
def extract_text(body: TextExtractIn):
    bits = "".join(
        "0" if char == ZERO_WIDTH_0 else "1"
        for char in body.text
        if char in (ZERO_WIDTH_0, ZERO_WIDTH_1)
    )
    if len(bits) < 8:
        return TextExtractOut(found=False)

    usable = len(bits) - len(bits) % 8
    payload = bytes(int(bits[i : i + 8], 2) for i in range(0, usable, 8)).decode("utf-8", "ignore")
    return TextExtractOut(found=bool(payload), payload=payload)

前端(Vue 3):渲染原文,复制/导出用水印版

vue 复制代码
<!-- components/TextWatermarkPanel.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const taskId = 2
const displayText = ref('')     // 页面上渲染的
const watermarkedText = ref('') // 只在复制/导出时使用
const message = ref('')

onMounted(async () => {
  const res = await fetch(`/api/watermark/text/content?task_id=${taskId}`, { credentials: 'include' })
  if (!res.ok) return

  const data = await res.json()
  displayText.value = data.text          // 可视区域用原文,避免零宽字符影响排版
  watermarkedText.value = data.watermarked
})

/** 复制:把肉眼相同的文本换成带水印的版本 */
async function copyWithWatermark() {
  await navigator.clipboard.writeText(watermarkedText.value)
  message.value = '已复制(附带隐形水印)'
}

/** 导出 txt:直接写水印版,全程不请求第二次 */
function exportTxt() {
  const blob = new Blob([watermarkedText.value], { type: 'text/plain;charset=utf-8' })
  const link = document.createElement('a')
  link.href = URL.createObjectURL(blob)
  link.download = `report-task-${taskId}.txt`
  link.click()
  URL.revokeObjectURL(link.href)
  message.value = '已导出'
}
</script>

<template>
  <div>
    <pre>{{ displayText }}</pre>
    <button @click="copyWithWatermark">复制(带水印)</button>
    <button @click="exportTxt">导出 txt</button>
    <p v-if="message">{{ message }}</p>
  </div>
</template>

注意事项

  • 一定要「渲染用原文、复制用水印版」,否则零宽字符混进 contenteditable 或表单里会引发校验异常;
  • navigator.clipboard 需要 HTTPS 或 localhost,否则要降级为隐藏 textarea + execCommand('copy');
  • 微信、部分富文本编辑器、输入框校验会清洗零宽字符,所以它适合 txt / 表格复制这类链路。

四、仿行伪列水印:查询接口直接掺入假数据

实现逻辑

后端在返回敏感数据的查询接口里,按比例混入「看起来是真的、业务上并不存在」的伪行。伪行用任务号做随机种子 生成,同一任务每次返回的伪行完全一致------数据一旦泄露,这批伪行就是指向泄露源的指纹。前端只负责渲染与导出,不能在页面上给伪行做任何标记,否则一眼就被剔除。

服务端(FastAPI)

python 复制代码
# server/routers/watermark.py(续)
class TableRowsOut(BaseModel):
    rows: list[dict[str, Any]]
    fake_count: int


def _query_real_rows() -> list[dict[str, Any]]:
    """真实数据来源:数据库 / 数据仓库,这里用假数据代替"""
    return [
        {"id": "A001", "region": "华东区", "amount": 58200},
        {"id": "A002", "region": "华南区", "amount": 43100},
        {"id": "A003", "region": "华北区", "amount": 62750},
    ]


def _fake_row(template: dict[str, Any], rnd: random.Random) -> dict[str, Any]:
    row: dict[str, Any] = {}
    for key, value in template.items():
        if isinstance(value, bool):
            row[key] = value
        elif isinstance(value, (int, float)):
            row[key] = round(value * (1 + rnd.uniform(-0.1, 0.1)), 2)  # 数值微扰,量级一致
        else:
            row[key] = value
    return row


@router.get("/table/rows", response_model=TableRowsOut, summary="查询结果直接掺入伪行")
def table_rows(
    task_id: int = Query(..., ge=1),
    ratio: float = Query(0.05, gt=0, le=0.3, description="伪行比例"),
):
    if task_id not in WATERMARK_TASKS:
        raise HTTPException(status_code=404, detail="任务不存在")

    rows = _query_real_rows()
    if not rows:
        return TableRowsOut(rows=[], fake_count=0)

    rnd = random.Random(task_id)  # 种子固定 → 伪行可复现
    count = max(1, round(len(rows) * ratio))
    fake_rows = [_fake_row(rows[rnd.randrange(len(rows))], rnd) for _ in range(count)]

    merged = [*rows, *fake_rows]
    rnd.shuffle(merged)
    return TableRowsOut(rows=merged, fake_count=count)

前端(Vue 3):渲染 + 导出 CSV

vue 复制代码
<!-- components/TableWatermarkPanel.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const taskId = 2
const tableData = ref<Record<string, any>[]>([])
const loading = ref(false)

onMounted(async () => {
  loading.value = true
  try {
    const res = await fetch(`/api/watermark/table/rows?task_id=${taskId}`, { credentials: 'include' })
    if (res.ok) tableData.value = (await res.json()).rows // 真伪混排,前端不做区分
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <div>
    <button :disabled="!tableData.length" @click="exportCsv(tableData, `task-${taskId}.csv`)">
      导出 CSV
    </button>

    <el-table :data="tableData" v-loading="loading">
      <el-table-column prop="id" label="编号" />
      <el-table-column prop="region" label="区域" />
      <el-table-column prop="amount" label="金额" />
    </el-table>
  </div>
</template>
ts 复制代码
// utils/exportCsv.ts
export function exportCsv(rows: Record<string, any>[], filename = 'export.csv') {
  const headers = Object.keys(rows[0] ?? {})
  const escape = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`
  const csv = [
    headers.join(','),
    ...rows.map(row => headers.map(key => escape(row[key])).join(','))
  ].join('\n')

  const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' }) // BOM 防 Excel 乱码
  const link = document.createElement('a')
  link.href = URL.createObjectURL(blob)
  link.download = filename
  link.click()
  URL.revokeObjectURL(link.href)
}

注意事项

  • 伪行位置必须由服务端决定,种子一旦下发到前端,伪行就能被反推、被剔除;
  • 全字符串的表(如姓名 + 地址)数值微扰无效,这类表要靠「插入不存在的编号」或伪列(虚构备注列)实现;
  • 前端拿到的就是混过伪行的结果集,不要再回写缓存或提交回后端,否则脏数据会污染真实库。

五、一键溯源:上传疑似泄露文件 → 接口提取 → 接口匹配

这是全文唯一需要前端提供文件的场景:页面渲染阶段不需要上传任何东西,但溯源时必须把泄露样本交给服务端解析。

实现逻辑

  1. 用户把疑似泄露的 txt / csv / 图片拖进来;
  2. 图片走 POST /image/extract(读像素里的任务号),文本走 POST /text/extract(读零宽字符);
  3. 拿到 task_id 后调 POST /trace,返回责任人、渠道、时间;
  4. 密钥与水印库全部留在服务端,前端只做类型判断和结果渲染。

服务端(FastAPI)

python 复制代码
# server/routers/watermark.py(续)
class TraceIn(BaseModel):
    task_id: int


class TraceOut(BaseModel):
    task_id: int
    user: str
    channel: str
    time: datetime


@router.post("/trace", response_model=TraceOut, summary="根据任务号溯源")
def trace(body: TraceIn):
    task = WATERMARK_TASKS.get(body.task_id)
    if not task:
        raise HTTPException(status_code=404, detail="未匹配到水印任务")
    return TraceOut(task_id=body.task_id, **task)

前端(Vue 3):拖拽 → 提取 → 查询 → 展示

vue 复制代码
<!-- components/TracePanel.vue -->
<script setup lang="ts">
import { ref } from 'vue'

interface TraceResult {
  task_id: number
  user: string
  channel: string
  time: string
}

const loading = ref(false)
const error = ref('')
const result = ref<TraceResult | null>(null)

async function extractTaskId(file: File): Promise<number | null> {
  // 图片:上传后由服务端读像素里的任务号
  if (file.type.startsWith('image/')) {
    const form = new FormData()
    form.append('file', file)
    const res = await fetch('/api/watermark/image/extract', { method: 'POST', body: form })
    if (!res.ok) return null
    return (await res.json()).task_id
  }

  // 文本 / CSV:上传文本内容,由服务端读零宽字符
  const res = await fetch('/api/watermark/text/extract', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text: await file.text() })
  })
  if (!res.ok) return null

  const data = await res.json()
  const matched = /Task_(\d+)/.exec(data.payload ?? '') // payload 形如 Bob/2026-11-05/Task_002
  return matched ? Number(matched[1]) : null
}

async function handleFile(file: File) {
  loading.value = true
  error.value = ''
  result.value = null

  try {
    const taskId = await extractTaskId(file)
    if (!taskId) {
      error.value = '未检测到水印信息,可能已被二次压缩或清洗'
      return
    }

    const res = await fetch('/api/watermark/trace', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ task_id: taskId })
    })
    if (!res.ok) throw new Error('未匹配到水印任务')

    result.value = await res.json()
  } catch (e) {
    error.value = (e as Error).message
  } finally {
    loading.value = false
  }
}

function onDrop(event: DragEvent) {
  event.preventDefault()
  const file = event.dataTransfer?.files?.[0]
  if (file) handleFile(file)
}
</script>

<template>
  <div class="trace-panel" @dragover.prevent @drop="onDrop">
    <p class="hint">把疑似泄露的文本、CSV 或图片拖到这里</p>

    <p v-if="loading">正在提取水印并溯源...</p>
    <p v-else-if="error" class="error">{{ error }}</p>

    <dl v-else-if="result">
      <dt>任务号</dt><dd>{{ result.task_id }}</dd>
      <dt>责任人</dt><dd>{{ result.user }}</dd>
      <dt>外发渠道</dt><dd>{{ result.channel }}</dd>
      <dt>外发时间</dt><dd>{{ new Date(result.time).toLocaleString() }}</dd>
    </dl>
  </div>
</template>

注意事项

  • 真实的图片泄露往往经过截图 / 有损压缩,LSB 会失效,生产环境建议用 DWT-DCT 或「服务端合成导出」的方式保证可提取;
  • 提取失败不要直接判定「无水印」,先判断样本是否被有损处理,再提示转人工核查;
  • 每次外发前都要先创建任务(发号 → 生成载体 → 记录责任人),溯源才有数据可查。
相关推荐
SameX23 分钟前
ArkTS 做应用内"快捷胶囊" —— 续航日记踩过的两个鸿蒙声明式 UI workaround
前端
贾公子29 分钟前
从单条内容到账号维度——AIGC 风险评分的工程实现
后端·python
木头左2 小时前
【Python】不点浏览器发博客:逆向 CSDN 创作中心接口做 md-publish 技能
python·自动化·api
孟陬2 小时前
Foobar 技术周刊#160:大模型让大规模形式化软件验证成为现实丨AI写C++代码之后会发生什么?丨我们真的需要alpha() 函数吗丨重试,有时会让故障更
前端·后端·typescript
Tt有了t3 小时前
设计模式速查
java·python·设计模式
deepseek233 小时前
Python日志拖慢请求?QueueHandler排队、丢失与退出实测
python·性能优化·logging·后端开发·queuehandler
flash俊杰3 小时前
屏幕感知双通道:截图之外,为什么还要一份 UIA 控件清单
前端
熟的生菜3 小时前
从零造一个腱绳驱动灵巧手——仿真器篇3:VTK 渲染与 3D 交互——看得见、点得着、转得动
前端
用户91294046848423 小时前
Codex 从零到能交付(05):第一次改代码:生成一个文件清单脚本
python