三
三股水
三
三股水

Obsidian 插件开发实战:给 Discuz 发布插件加上多站点、登录态与马甲功能

本文从 Obsidian 撰写发布

Obsidian 插件开发实战:给 Discuz 发布插件加上多站点、登录态与马甲功能

在上一篇的基础上,继续给插件加功能。本文记录一次完整的扩展过程,包括多站点管理、登录态 UI、超级管理员识别、马甲发帖,以及踩过的坑。

一、需求梳理

在已有"单站点 + 发帖"的基础上,追加以下功能:

  1. 多站点配置:一个插件管多个论坛,可切换
  2. 登录/退出 UI:设置页显示当前登录用户
  3. 超管识别:登录后自动判断是否超级管理员
  4. 马甲功能:超管可配置多个马甲账号,发帖时选择身份
  5. 回写 frontmatter:发帖成功后在笔记头部记录 tid、url、身份

二、多站点改造

2.1 数据结构从单站点改为数组

plugin_settings.ts:

export interface SiteConfig {
  id: string;
  name: string;
  apiBase: string;      // https://xxx/api/restful/
  siteUrl: string;      // https://xxx
  appid: string;
  secret: string;
  username: string;
  password: string;
  defaultFid: number;
  // 运行时缓存
  token?: string | null;
  tokenExpiry?: number;
  uid?: number;
  // 登录态
  userInfo?: any;
  isAdmin?: boolean;
  // 马甲:格式 "用户名:密码,用户名:密码"
  aliases?: string;
}

export interface DiscuzSettings {
  sites: SiteConfig[];
  activeSiteId: string;
}

export const DEFAULT_SETTINGS: DiscuzSettings = {
  sites: [],
  activeSiteId: "",
};

export function generateSiteId(): string {
  return "site_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

export function createDefaultSite(name = "新站点"): SiteConfig {
  return {
    id: generateSiteId(),
    name,
    apiBase: "",
    siteUrl: "",
    appid: "",
    secret: "",
    username: "",
    password: "",
    defaultFid: 2,
    aliases: "",
  };
}

/** 解析马甲字符串为 [{username, password}] */
export function parseAliases(aliases: string | undefined): { username: string; password: string }[] {
  if (!aliases) return [];
  return aliases
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean)
    .map((pair) => {
      const idx = pair.indexOf(":");
      if (idx === -1) return { username: pair, password: "" };
      return {
        username: pair.slice(0, idx).trim(),
        password: pair.slice(idx + 1).trim(),
      };
    });
}

2.2 HttpUtils 改造为"当前站点"上下文

request.ts 里 HttpUtils 不再持有固定的 appid/secret,而是通过 setSite() 切换:

export class HttpUtils {
  private static site: SiteConfig | null = null;
  private static token: string | null = null;
  private static tokenExpiry = 0;
  private static loggedIn = false;
  private static uid = 0;
  private static userInfo: any = null;

  static setSite(site: SiteConfig) {
    this.site = site;
    this.token = site.token || null;
    this.tokenExpiry = site.tokenExpiry || 0;
    this.uid = site.uid || 0;
    this.userInfo = site.userInfo || null;
    this.loggedIn = false;
  }

  static getSite(): SiteConfig | null {
    return this.site;
  }

  static isLoggedIn(): boolean {
    return this.loggedIn && !!this.userInfo;
  }

  // ... 其余不变,只是内部的 this.site.appid / secret 从当前 site 取
}

三、登录/退出 UI

3.1 设置页标题行显示登录态

站点卡片标题行:站点名 + 👤 用户名 + 超管标记 + 按钮组(当前/登录/退出/删除/折叠)。

const titleWrap = header.createDiv({
  attr: { style: "display: flex; align-items: center; gap: 8px; flex: 1;" },
});
titleWrap.createEl("strong", {
  text: site.name || `站点 ${index + 1}`,
  attr: { style: "font-size: 14px;" },
});

if (site.userInfo?.username) {
  titleWrap.createEl("span", {
    text: `👤 ${site.userInfo.username}`,
    attr: { style: "font-size: 12px; color: var(--text-muted);" },
  });
  if (site.isAdmin) {
    titleWrap.createEl("span", {
      text: "超管",
      attr: {
        style: "font-size: 11px; color: var(--text-success);",
      },
    });
  }
}

3.2 登录/退出按钮

if (site.userInfo?.username) {
  const logoutBtn = btnGroup.createEl("button", { text: "退出" });
  logoutBtn.addEventListener("click", async () => {
    HttpUtils.setSite(site);
    HttpUtils.logout();
    delete site.userInfo;
    delete site.isAdmin;
    delete site.token;
    delete site.tokenExpiry;
    await this.plugin.saveSettings();
    new Notice("已退出登录");
    this.display();
  });
} else {
  const loginBtn = btnGroup.createEl("button", { text: "登录" });
  loginBtn.addEventListener("click", async () => {
    try {
      HttpUtils.setSite(site);
      await HttpUtils.login();
      await this.plugin.saveSettings();
      new Notice("✅ 登录成功:" + (site.userInfo?.username || ""));
      this.display();
    } catch (e) {
      new Notice("❌ " + (e instanceof Error ? e.message : String(e)));
    }
  });
}

四、超管识别

4.1 从登录返回判断

/member/login 返回的 user 对象里包含用户组信息。日志实测:

{
  "uid": 1,
  "username": "XIGE",
  "groupid": "1",
  "adminid": "1",
  "groupname": "管理员",
  "allowadmincp": "1"
}

判断条件:

const isAdmin =
  user?.adminid === 1 ||
  user?.adminid === "1" ||
  user?.group?.radminid === "1" ||
  user?.group?.radminid === 1 ||
  user?.groupid === 1 ||
  user?.groupid === "1";
this.site.isAdmin = isAdmin;

注意:adminid 和 groupid 都是字符串 "1",不是数字。判断要兼容两种类型。

4.2 马甲设置仅超管可见

if (site.isAdmin) {
  // 渲染马甲设置区
}

五、马甲发帖(核心)

5.1 Discuz Token 与用户 Session 的关系

这是整个功能最难的点。Discuz 的 RESTful API 里:

  • Token:应用级,代表"哪个应用在调接口",有效期 1 小时
  • 用户 Session:通过 cookie auth + saltkey 标识"当前是谁"

调 /member/login 只是验证账号密码,不会自动切换 Token 绑定的用户。需要:

  1. 登录成功
  2. 清空当前 Token
  3. 重新获取 Token(此时新 Token 与刚登录的用户绑定)
  4. 再调一次 /member/login 验证身份

5.2 loginAs 实现

static async loginAs(username: string, password: string): Promise<any> {
  if (!this.site) throw new Error("未设置站点");

  // 1. 先用旧 token 调登录接口
  const res = await this.post("/member/login", { username, password });
  if (res.ret > 0) {
    throw new Error(
      `马甲 ${username} 登录失败: ` +
        (res.msg?.message || res.msg || JSON.stringify(res))
    );
  }

  // 2. 清空当前 token,强制重新获取
  this.token = null;
  this.tokenExpiry = 0;
  this.site.token = null;
  this.site.tokenExpiry = 0;
  this.loggedIn = false;
  this.userInfo = null;
  this.site.userInfo = null;

  // 3. 重新获取 token(服务端此时记住新用户)
  await this.ensureToken();

  // 4. 再登录一次验证身份
  const verify = await this.post("/member/login", { username, password });
  const verifyUser = verify.user || verify.data?.user;

  this.loggedIn = true;
  this.userInfo = verifyUser;
  this.site.userInfo = verifyUser;
  if (verifyUser?.uid) {
    this.uid = verifyUser.uid;
    this.site.uid = verifyUser.uid;
  }

  return verify;
}

5.3 发帖流程:切身份 → 发帖 → 恢复主账号

push.ts 的 publish():

async publish() {
  if (!this.title || !this.content) {
    new Notice("标题或内容不能为空");
    return;
  }
  this.notice = new Notice("发布中...");
  try {
    // 1. 确定发帖身份
    if (this.selectedAlias) {
      const aliases = parseAliases(this.currentSite.aliases);
      const alias = aliases.find((a) => a.username === this.selectedAlias);
      if (!alias) {
        new Notice("❌ 未找到马甲:" + this.selectedAlias);
        this.notice.hide();
        return;
      }
      await HttpUtils.loginAs(alias.username, alias.password);
    } else {
      await HttpUtils.loginAs(
        this.currentSite.username,
        this.currentSite.password
      );
    }

    // 2. 发帖
    let body = stripFrontmatter(this.content);
    body = markdownToBBCode(body);

    const res = await HttpUtils.post("/post/newthread", {
      fid: this.selectedFid,
      subject: this.title,
      message: body,
    });

    if (res.ret === 0) {
      const tid = res.tid || res.data?.tid;
      const url = tid && this.currentSite.siteUrl
        ? `${this.currentSite.siteUrl}/forum.php?mod=viewthread&tid=${tid}`
        : "";

      if (tid && this.activeFilePath) {
        await this.writeBackFrontmatter(this.activeFilePath, tid, url);
      }

      new Notice(
        "✅ 发布成功" +
          (this.selectedAlias ? ` (${this.selectedAlias})` : "") +
          (tid ? ` tid: ${tid}` : "")
      );
      if (url) window.open(url, "_blank");
    } else {
      new Notice("❌ 发布失败: " + (res.msg?.message || res.msg || "未知错误"));
    }

    // 3. 恢复主账号身份
    try {
      await HttpUtils.loginAs(
        this.currentSite.username,
        this.currentSite.password
      );
    } catch (e) {
      console.error("[Discuz] 恢复主账号失败:", e);
    }

    this.close();
  } catch (e) {
    console.error(e);
    new Notice("❌ " + (e instanceof Error ? e.message : String(e)));
    try {
      await HttpUtils.loginAs(
        this.currentSite.username,
        this.currentSite.password
      );
    } catch {}
  } finally {
    this.notice.hide();
  }
}

六、回写 frontmatter

发帖成功后在笔记头部写入:

---
title: 笔记标题
discuz:
  tid: 232
  fid: 31
  site: 站点名称
  as: 马甲用户名    # 仅马甲发帖时才有
  url: https://xxx/forum.php?mod=viewthread&tid=232
  published: 2026-09-17T14:40:02.000Z
---

实现:

private async writeBackFrontmatter(path: string, tid: number, url: string) {
  const file = this.app.vault.getFileByPath(path);
  if (!file) return;
  let content = await this.app.vault.read(file);

  const now = new Date().toISOString();
  const discuzYaml = [
    `discuz:`,
    `  tid: ${tid}`,
    `  fid: ${this.selectedFid}`,
    `  site: ${this.currentSite.name}`,
    this.selectedAlias ? `  as: ${this.selectedAlias}` : "",
    `  url: ${url}`,
    `  published: ${now}`,
  ]
    .filter(Boolean)
    .join("\n");

  const fmRegex = /^---\n([\s\S]*?)\n---\n?/;
  const match = content.match(fmRegex);

  if (match) {
    let fm = match[1];
    fm = fm.replace(/\ndiscuz:\n(?:[ \t]+.*\n?)*/g, "\n");
    fm = fm.replace(/^discuz:\n(?:[ \t]+.*\n?)*/g, "");
    const newFm = fm.trimEnd() + "\n" + discuzYaml;
    content = content.replace(fmRegex, `---\n${newFm}\n---\n`);
  } else {
    content = `---\n${discuzYaml}\n---\n\n${content}`;
  }

  await this.app.vault.modify(file, content);
}

七、UI 细节

7.1 站点卡片折叠

网站多了以后页面会拉得很长,给每个站点卡片加折叠按钮:

const toggleBtn = btnGroup.createEl("button", {
  text: "▼",
  attr: {
    style: "width: 28px; padding: 0; font-size: 11px;",
    title: "展开/收起设置",
  },
});

const detailWrap = siteBox.createDiv({
  attr: { style: "display: none; margin-top: 10px;" },
});

toggleBtn.addEventListener("click", () => {
  const expanded = detailWrap.style.display !== "none";
  detailWrap.style.display = expanded ? "none" : "block";
  toggleBtn.setText(expanded ? "▼" : "▲");
});

默认收起,点击 ▼ 展开。

7.2 去掉标题左缩进

Obsidian 的 h2 / h3 默认有 padding-left,视觉上比卡片往右偏。加样式去掉:

containerEl.createEl("h2", {
  text: "Discuz 发布设置",
  attr: { style: "padding-left: 0; margin-left: 0;" },
});

containerEl.createEl("h3", {
  text: "站点列表",
  attr: { style: "padding-left: 0; margin-left: 0;" },
});

八、踩过的坑

8.1 马甲登录不生效

现象:loginAs("ceshi1", ...) 返回的用户还是 XIGE。

原因:Discuz Token 是应用级的,/member/login 只验证密码,不改 Token 与用户的绑定。

解决:登录成功后清空 Token,重新获取,再登录验证。这就是 loginAs 四步流程的由来。

8.2 连续切马甲时,第二次没生效

现象:主账号 → ceshi1 发帖成功;ceshi1 → ceshi2 发帖,作者还是 ceshi1。

原因:为了"优化性能",加了"身份相同就跳过 loginAs"的判断。但 Token 被第一次切换改过,跳过逻辑让第二次的切换被跳过了。

解决:不要加任何跳过逻辑。每次 loginAs 无条件完整执行四步流程。性能损失几百毫秒,但可靠性 100%。

教训:不要在没有充分测试的情况下"优化"已经跑通的核心逻辑。加缓存、加跳过判断、加条件分支,都会引入新的边界问题。

8.3 发帖后身份没恢复

现象:用马甲发帖后,设置页显示的用户变成了马甲,马甲设置区消失。

原因:loginAs 会更新 site.userInfo 为当前登录用户,发完帖没有切回主账号。

解决:publish() 的最后,无论成功失败都执行一次 loginAs(主账号, 主密码),恢复身份。

8.4 adminid 是字符串

现象:user.adminid === 1 判断为 false。

原因:Discuz 返回的 adminid 是字符串 "1",不是数字 1。

解决:判断时兼容两种类型:

user?.adminid === 1 || user?.adminid === "1"

8.5 设置页字段值显示为空

现象:明明保存了用户名密码,但重新打开设置页显示为空。

原因:new Setting(...).addText((t) => t.setValue(...)) 是在 display() 调用时求值,如果 site[key] 是 undefined,String(undefined) 会变成 "undefined"。要用 String(site[key] ?? "")。

8.6 点"登录"没填信息也能成功

现象:空表单点登录,居然成功了。

原因:data.json 里已经存了上次填的账号密码,saveSettings() 会持久化。UI 上看不到只是没显示在输入框里,实际值还在。

不是 bug,但初次用会觉得奇怪。可以在 UI 上提示"请先填写账号密码"。

九、经验总结

9.1 先跑通,再优化

最重要的教训。马甲功能跑通后,试图加"跳过相同身份登录"和"版块列表缓存"两个优化,结果连续两次把核心逻辑改坏。回退后又发现回退不干净。

原则:

  • 核心流程(登录、发帖)一旦跑通,不动
  • 优化只加在外围(UI、日志、提示)
  • 每次改动前先备份当前可用版本

9.2 最小改动

改代码时,只改需要改的那一行。不要顺手重构、不要顺手改命名、不要顺手加日志。每一次"顺手"都是一个潜在的 bug。

9.3 关键操作加日志

涉及网络请求、身份切换、状态变更的地方,加 console.log。调试完再删。本次开发中,正是靠 [Discuz] loginAs 验证返回 user: 日志才发现身份没切换的问题。

9.4 数据结构变化要同步改所有引用

从单站点改多站点时,所有 settings.xxx 都要改成 site.xxx,容易漏。改完搜一遍关键词,确认没有残留。

9.5 不要相信"这个优化不会有问题"

加缓存、加跳过、加合并请求,看起来是优化,实际上引入了新的状态和边界条件。除非有明确的性能数据支撑,否则不要优化。

十、最终文件清单

扩展后涉及的文件:

src/
├── main.ts                     # 入口,加载站点,注册 Ribbon
├── setting/
│   ├── plugin_settings.ts      # SiteConfig + DiscuzSettings + parseAliases
│   └── setting_tab.ts          # 站点列表 UI + 登录/退出 + 马甲设置 + 折叠
├── utils/
│   ├── request.ts              # HttpUtils:签名、Token、loginAs
│   └── converter.ts            # Markdown → BBCode
└── view/
    └── push.ts                 # 发布弹窗:身份选择 + 发帖 + 回写

十一、未来可扩展方向

  • 图片上传:/upload/post 上传文件,拿 aid 后插入 [attachimg]aid[/attachimg]
  • 更新已发布帖子:frontmatter 有 tid 时走编辑接口而非新建
  • 发布前 BBCode 预览:弹窗里加折叠区显示转换结果
  • 清理调试日志:发布前删掉 console.log
  • 删除帖子:调对应的删除接口
  • 同步评论:拉取帖子回复到笔记

十二、参考

  • Discuz RESTful API:https://gitee.com/Discuz/discuz-restful-api
  • Obsidian 插件示例:https://github.com/obsidianmd/obsidian-sample-plugin
  • Obsidian API 文档:https://docs.obsidian.md/
上一篇 Obsidian 发布插件开发教程:对接 Discuz X5 RESTful API 下一篇 Obsidian 小红书同步插件(Xiaohongshu Sync)今日修复与 AI 模块开发记录

暂无评论

Ctrl + Enter 发送

还没有评论,来说点什么吧

© 2026 三股水