Browse Source

会议纪要分享:链接最后一段改成服务端签发的只读票据,不再放登录 JWT

链接格式 share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{ticket}。最后一段
以前放的是 logintoken(完整登录 JWT)——谁拿到链接就拿到了整个账号,转发进群、
被截图、进浏览器历史都等于把账号交出去,而且除了改密码没有任何失效手段。
现在是 HMAC 签名的分享票据:绑定单条记录、只读、默认 7 天过期,密钥由
FIELD_ENCRYPT_KEY 派生(上线不需要新配置项)。

- echomeet_getsharedrecord 必须在 gateway 白名单里——打开链接的人本来就没登录,
  漏了是 code:18、表现为「分享链接打开一片空白」。签发端 echomeet_createsharelink
  反之,不要加进白名单。
- 落地页嵌在 gateway 二进制里(go:embed),路由 GET /share/*any 必须是 catch-all:
  路径里的段是数据不是文件路径,gin.Static 会去磁盘找同名文件、一律 404。
- 落地页的 ALLOWED_HOSTS 白名单不能删:serverHost 来自 URL,不校验就等于「构造
  一条链接让别人的浏览器带着票据去请求任意域名」。新增环境要加一行。
- 极简 Markdown 渲染器先 esc() 再套正则,块级语法要匹配转义后的形态(引用块匹配
  的是 > 不是 >),写成 > 不报错、只会静默退化成普通段落。

客户端侧:分享前先申请票据并加载提示,拿不到就不分享出一条必然打不开的链接。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
9f105bf7c8
  1. 13
      apps/client/lib/data/services/network/api.dart
  2. 37
      apps/client/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart
  3. 38
      apps/proto/echomeet/echomeet_msg.proto
  4. 199
      apps/services/modules/echomeet/api_sharelink.go
  5. 107
      apps/services/modules/echomeet/api_sharelink_test.go
  6. 37
      apps/services/modules/gateway/share_page.go
  7. 316
      apps/services/modules/gateway/share_page.html
  8. 2
      apps/services/modules/gateway/wservice_comp.go
  9. 370
      apps/services/pb/echomeet_msg.pb.go
  10. 6
      deploy/app/confs/gateway.yaml.example
  11. 196
      docs/会议纪要分享-落地与上线步骤.md

13
apps/client/lib/data/services/network/api.dart

@ -519,6 +519,19 @@ class Api {
); );
} }
// 为一条会议记录签发分享票据。入参 {id, expiredays?},回 {ticket, expiretime}。
//
// ⚠️ 分享链接里放的必须是这个票据,**不能再放 logintoken**:那是完整登录态,
// 拿到链接就等于拿到账号,且除了改密码没有任何失效手段。票据只对应这一条记录、
// 只读、默认 7 天过期(服务端 modules/echomeet/api_sharelink.go)。
static createShareLinkEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_createsharelink',
params: params,
);
}
static readRecordEchomeet([params]) { static readRecordEchomeet([params]) {
return DioManager().request( return DioManager().request(
NWMethod.post, NWMethod.post,

37
apps/client/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart

@ -7,7 +7,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:htmltopdfwidgets/htmltopdfwidgets.dart' as pdf; import 'package:htmltopdfwidgets/htmltopdfwidgets.dart' as pdf;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@ -15,6 +14,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import '../../../../core/utils/logger.dart'; import '../../../../core/utils/logger.dart';
import '../../../../data/services/network/api.dart';
import '../../../../data/services/network/dio_manager.dart'; import '../../../../data/services/network/dio_manager.dart';
import '../../controllers/meeting_details_controller.dart'; import '../../controllers/meeting_details_controller.dart';
import '../../controllers/mind_map_controller.dart'; import '../../controllers/mind_map_controller.dart';
@ -233,20 +233,41 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
// 功能说明:生成会议分享链接并通过系统分享面板分享 // 功能说明:生成会议分享链接并通过系统分享面板分享
// 执行流程: // 执行流程:
// 1. 关闭底部弹窗 // 1. 关闭底部弹窗
// 2. 从本地存储获取用户登录token // 2. 向服务端申请**只读分享票据**(echomeet_createsharelink)
// 3. 获取会议ID和服务器地址 // 3. 获取会议ID和服务器地址
// 4. 构建分享URL格式:https://share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{token} // 4. 构建分享URL:https://share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{ticket}
// 注:路径第二段是品牌标识、不是要请求的域名,由 share.ymaikj.com 那端解析 // · 第二段 ymaikj.com 是品牌标识、不是要请求的域名,由落地页解析
// · {serverHost} 告诉落地页去哪个后端取数据;落地页那边有域名白名单
// 5. 调用SharePlus分享链接 // 5. 调用SharePlus分享链接
// 6. 分享成功后显示提示 // 6. 分享成功后显示提示
//
// ⚠️ 最后一段以前放的是 storage.read("logintoken") —— 用户的完整登录 JWT。
// 链接转发进群、被截图、进浏览器历史,都等于把账号交出去,而且除了改密码
// 没有任何失效手段。2026-09-23 改成服务端签发的票据:只含这一条记录、
// 只读、默认 7 天过期。**不要改回去。**
onTap: () async { onTap: () async {
// 在关闭弹窗前捕获分享位置(iPad需要) // 在关闭弹窗前捕获分享位置(iPad需要)
final origin = _getSharePositionOrigin(); final origin = _getSharePositionOrigin();
Get.back(); Get.back();
try { try {
final GetStorage storage = GetStorage(); final int id = _controller.meetingData.value.id;
String token = storage.read("logintoken") ?? '';
int id = _controller.meetingData.value.id; // 票据要走一次网络,慢网下没有反馈会让人以为按钮没响应
EasyLoading.show();
String ticket = '';
try {
final res = await Api.createShareLinkEchomeet(
{'id': id, 'expiredays': 7});
ticket = (res?['ticket'] ?? '').toString();
} finally {
EasyLoading.dismiss();
}
// 拿不到票据就别分享出一条必然打不开的链接
if (ticket.isEmpty) {
EasyLoading.showToast(
'operationFailedPleaseRetry'.tr); // 对应中文:操作失败,请重试
return;
}
// 获取动态服务器地址 // 获取动态服务器地址
final serverUrl = DioManager().baseUrl; final serverUrl = DioManager().baseUrl;
@ -260,7 +281,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
subject: subject:
'Eaimar WEB,由DeepSound.Ai提供支持-实时记录会议纪要,多语言翻译。', 'Eaimar WEB,由DeepSound.Ai提供支持-实时记录会议纪要,多语言翻译。',
uri: Uri.parse( uri: Uri.parse(
'https://share.ymaikj.com/share/$serverHost/ymaikj.com/$id/$token', 'https://share.ymaikj.com/share/$serverHost/ymaikj.com/$id/$ticket',
), ),
sharePositionOrigin: origin, sharePositionOrigin: origin,
)); ));

38
apps/proto/echomeet/echomeet_msg.proto

@ -231,3 +231,41 @@ message EchomeetAliSentence {
message EchomeetAliCallbackResp { message EchomeetAliCallbackResp {
} }
// ============================== 会议纪要分享 ==============================
//
// 分享链接形如 https://share.ymaikj.com/share/{serverHost}/{brand}/{id}/{ticket}
//
// ⚠️ 2026-09-23 之前客户端把**用户的登录 JWT**直接拼进这个 URL,谁拿到链接就拿到了
// 整个账号。现在改成服务端签发的分享票据:只含一条记录、只读、有有效期。
message EchomeetCreateShareLinkReq{
uint64 id = 1; //@go_tags(`json:"id"`) 会议记录id
int32 expiredays = 2; //@go_tags(`json:"expiredays"`) 有效天数,<=0 用默认(7),上限 30
}
message EchomeetCreateShareLinkResp{
string ticket = 1; //@go_tags(`json:"ticket"`) 分享票据,拼进落地页 URL 的最后一段
int64 expiretime = 2; //@go_tags(`json:"expiretime"`) 到期时间 unix 秒
}
// 落地页按票据取只读内容。**免登录**,须在 gateway 的 WhiteList 里。
message EchomeetGetSharedRecordReq{
string ticket = 1; //@go_tags(`json:"ticket"`) 分享票据
}
// 只回落地页要展示的字段。刻意不含 uid / filepath / taskid / logid / *_svc_id:
// 这是一个任何人凭链接就能打开的接口,多回一个字段就是多泄一份内部信息。
message EchomeetGetSharedRecordResp{
uint64 id = 1; //@go_tags(`json:"id"`) 会议记录id
string title = 2; //@go_tags(`json:"title"`) 标题
int64 creationtime = 3; //@go_tags(`json:"creationtime"`) 创建时间 unix 秒
int32 seconds = 4; //@go_tags(`json:"seconds"`) 时长 秒
string audiourl = 5; //@go_tags(`json:"audiourl"`) 音频url
string original = 6; //@go_tags(`json:"original"`) 听写内容
string translate = 7; //@go_tags(`json:"translate"`) 翻译
string overview = 8; //@go_tags(`json:"overview"`) 短总结
string summary = 9; //@go_tags(`json:"summary"`) 总结
string formlanguage = 10; //@go_tags(`json:"formlanguage"`) 源语言
string tolanguage = 11; //@go_tags(`json:"tolanguage"`) 目标语言
int64 expiretime = 12; //@go_tags(`json:"expiretime"`) 本链接到期时间 unix 秒
}

199
apps/services/modules/echomeet/api_sharelink.go

@ -0,0 +1,199 @@
package echomeet
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
"yunyan/comm"
"yunyan/pb"
)
/*
会议纪要分享:服务端签发的只读票据。
⚠️ 2026-09-23 之前,客户端把**用户的登录 JWT**(GetStorage 里的 logintoken)直接拼进
分享 URL 的最后一段。谁拿到那条链接就拿到了整个账号——能调任何业务接口、看全部纪要、
消费算力,而且除了改密码没有任何失效手段。链接一旦转发进群或被截图就收不回来。
现在换成票据:一条票据只对应**一条记录**、只能读、有有效期,且不含任何可用于其它接口的凭据。
票据格式(自包含,不落库):
<payload>.<sig>
payload = base64url(JSON{"i":记录id,"u":owner uid,"e":到期unix秒})
sig = base64url(HMAC-SHA256(shareKey, payload))
为什么不落库:分享是读多写少、量小且可失效期自然收敛的场景,落库要多一张表 + 清理任务,
而票据本身已经带了全部信息。代价是**签发后无法单独撤销**(只能等过期或换密钥),
这是刻意接受的取舍——真要撤销能力时再补一张吊销表即可,票据格式不用变。
*/
const (
// shareTicketDefaultDays 不指定时的有效期。
shareTicketDefaultDays = 7
// shareTicketMaxDays 有效期上限。链接是公开可转发的,无限期等于永久泄露。
shareTicketMaxDays = 30
// shareKeyContext 密钥派生的域分隔串。换它等于让全部存量票据立刻失效。
shareKeyContext = "echomeet.share.v1"
)
var errShareTicketInvalid = errors.New("分享链接无效或已过期")
// sharePayload 票据载荷。字段名压到一个字母:它要进 URL,越短越不容易被中间环节截断。
type sharePayload struct {
Id uint64 `json:"i"`
Uid string `json:"u"`
Expire int64 `json:"e"`
}
// shareKey 从 FIELD_ENCRYPT_KEY 派生分享票据的签名密钥。
//
// 为什么派生而不是直接用:FIELD_ENCRYPT_KEY 是 svc_config 里第三方凭据的加密密钥,
// 拿它直接签票据会让两个用途共用同一份秘密。HMAC 是单向的,从派生key 推不回原 key。
//
// 为什么选 FIELD_ENCRYPT_KEY 而不是新加一个配置项:它已经在每台机的 .env 里
// (console 与各业务服务本就必须配同一把),新增配置项意味着上线时漏配一处就整个功能不可用,
// 而且漏配的表现是「分享按钮点了报错」这种要到真机才发现的问题。
func shareKey() ([]byte, error) {
base := strings.TrimSpace(os.Getenv("FIELD_ENCRYPT_KEY"))
if base == "" {
// 刻意不退化成空密钥签名:那样任何人都能自己造票据读任意记录。
return nil, errors.New("缺少 FIELD_ENCRYPT_KEY,无法签发分享票据")
}
m := hmac.New(sha256.New, []byte(base))
m.Write([]byte(shareKeyContext))
return m.Sum(nil), nil
}
// signShareTicket 签发票据。
func signShareTicket(p sharePayload) (string, error) {
key, err := shareKey()
if err != nil {
return "", err
}
raw, err := json.Marshal(p)
if err != nil {
return "", err
}
body := base64.RawURLEncoding.EncodeToString(raw)
m := hmac.New(sha256.New, key)
m.Write([]byte(body))
return body + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)), nil
}
// parseShareTicket 校验并解出票据载荷。
//
// ⚠️ 「签名不对」「格式不对」「已过期」一律回同一个 errShareTicketInvalid。
// 分开回等于告诉攻击者「这条票据是真的、只是过期了」,同 recordErr 的理由。
func parseShareTicket(ticket string) (*sharePayload, error) {
ticket = strings.TrimSpace(ticket)
i := strings.LastIndex(ticket, ".")
if i <= 0 || i == len(ticket)-1 {
return nil, errShareTicketInvalid
}
body, sig := ticket[:i], ticket[i+1:]
key, err := shareKey()
if err != nil {
return nil, err
}
m := hmac.New(sha256.New, key)
m.Write([]byte(body))
want := m.Sum(nil)
got, err := base64.RawURLEncoding.DecodeString(sig)
// hmac.Equal 是常数时间比较:用 == 比会让攻击者靠计时逐字节试出正确签名。
if err != nil || !hmac.Equal(want, got) {
return nil, errShareTicketInvalid
}
raw, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
return nil, errShareTicketInvalid
}
p := &sharePayload{}
if err = json.Unmarshal(raw, p); err != nil {
return nil, errShareTicketInvalid
}
if p.Id == 0 || p.Uid == "" || p.Expire <= 0 || time.Now().Unix() > p.Expire {
return nil, errShareTicketInvalid
}
return p, nil
}
// @Summary 创建会议纪要分享链接
// @Description 为自己的一条会议记录签发只读分享票据,客户端拼成落地页 URL
// @Tags Echomeet
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param user body pb.EchomeetCreateShareLinkReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.EchomeetCreateShareLinkResp} "响应数据"
// @Router /api/home/echomeet_createsharelink [post]
func (this *apiComp) CreateShareLink(session comm.IUserSession, req *pb.EchomeetCreateShareLinkReq) (resp *pb.EchomeetCreateShareLinkResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
// 先确认这条记录确实是本人的:签发前不校验的话,改个 id 就能给别人的纪要签出公开链接。
if _, err := this.module.model.getrecordforuid(uid, req.Id); err != nil {
errdata = recordErr(err)
return
}
days := int(req.Expiredays)
if days <= 0 {
days = shareTicketDefaultDays
}
if days > shareTicketMaxDays {
days = shareTicketMaxDays
}
expire := time.Now().AddDate(0, 0, days).Unix()
ticket, err := signShareTicket(sharePayload{Id: req.Id, Uid: uid, Expire: expire})
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: err.Error()}
return
}
resp = &pb.EchomeetCreateShareLinkResp{Ticket: ticket, Expiretime: expire}
return
}
// @Summary 按分享票据读取会议纪要
// @Description 落地页调用,**免登录**,须在 gateway 的 WhiteList 里
// @Tags Echomeet
// @Accept json
// @Produce json
// @Param user body pb.EchomeetGetSharedRecordReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.EchomeetGetSharedRecordResp} "响应数据"
// @Router /api/home/echomeet_getsharedrecord [post]
func (this *apiComp) GetSharedRecord(session comm.IUserSession, req *pb.EchomeetGetSharedRecordReq) (resp *pb.EchomeetGetSharedRecordResp, errdata *pb.ErrorData) {
// 注意:这里**不能**调 requireUID —— 本接口的全部意义就是让没登录的人也能打开链接。
p, err := parseShareTicket(req.Ticket)
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: err.Error()}
return
}
// 仍按 (uid, id) 查:票据签发时绑定了 owner,记录易主或被删后票据自然失效。
model, err := this.module.model.getrecordforuid(p.Uid, p.Id)
if err != nil {
// 记录已删时同样回「链接无效」,不区分——链接是公开的,不该成为探测记录存在性的入口。
errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: errShareTicketInvalid.Error()}
return
}
resp = &pb.EchomeetGetSharedRecordResp{
Id: model.Id,
Title: model.Title,
Creationtime: model.Creationtime,
Seconds: model.Seconds,
Audiourl: model.Audiourl,
Original: model.Original,
Translate: model.Translate,
Overview: model.Overview,
Summary: model.Summary,
Formlanguage: model.Formlanguage,
Tolanguage: model.Tolanguage,
Expiretime: p.Expire,
}
return
}

107
apps/services/modules/echomeet/api_sharelink_test.go

@ -0,0 +1,107 @@
package echomeet
import (
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
"time"
)
// 票据是公开链接里的唯一凭据,下面每条都是「错了就等于任何人能读任意纪要」级别的性质,
// 所以全部钉死在测试里。
func TestShareTicketRoundTrip(t *testing.T) {
os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789")
defer os.Unsetenv("FIELD_ENCRYPT_KEY")
exp := time.Now().Add(time.Hour).Unix()
ticket, err := signShareTicket(sharePayload{Id: 42, Uid: "1234567890", Expire: exp})
if err != nil {
t.Fatalf("签发失败: %v", err)
}
got, err := parseShareTicket(ticket)
if err != nil {
t.Fatalf("校验失败: %v", err)
}
if got.Id != 42 || got.Uid != "1234567890" || got.Expire != exp {
t.Fatalf("载荷不一致: %+v", got)
}
}
func TestShareTicketRejects(t *testing.T) {
os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789")
defer os.Unsetenv("FIELD_ENCRYPT_KEY")
good, _ := signShareTicket(sharePayload{Id: 42, Uid: "u1", Expire: time.Now().Add(time.Hour).Unix()})
body := good[:strings.LastIndex(good, ".")]
// 改载荷不改签名:这是最直接的攻击——把 id 换成别人的记录。
tampered := base64.RawURLEncoding.EncodeToString([]byte(`{"i":43,"u":"u1","e":9999999999}`))
cases := map[string]string{
"空串": "",
"没有分隔点": body,
"签名被截断": body + ".",
"签名乱填": body + ".AAAA",
"载荷被篡改": tampered + good[strings.LastIndex(good, "."):],
"整段不是base64": "!!!.@@@",
}
for name, tk := range cases {
if _, err := parseShareTicket(tk); err == nil {
t.Errorf("%s: 本该被拒,却通过了", name)
}
}
// 过期票据必须拒绝:签名是对的,只有时间不对。
expired, _ := signShareTicket(sharePayload{Id: 42, Uid: "u1", Expire: time.Now().Add(-time.Second).Unix()})
if _, err := parseShareTicket(expired); err == nil {
t.Error("过期票据本该被拒")
}
}
// 换密钥必须让旧票据全部失效——否则「换密钥」这个应急手段是假的。
func TestShareTicketKeyIsolation(t *testing.T) {
os.Setenv("FIELD_ENCRYPT_KEY", "key-A")
ticket, err := signShareTicket(sharePayload{Id: 1, Uid: "u", Expire: time.Now().Add(time.Hour).Unix()})
if err != nil {
t.Fatal(err)
}
os.Setenv("FIELD_ENCRYPT_KEY", "key-B")
defer os.Unsetenv("FIELD_ENCRYPT_KEY")
if _, err = parseShareTicket(ticket); err == nil {
t.Fatal("换密钥后旧票据仍然有效")
}
}
// 没有 FIELD_ENCRYPT_KEY 时必须报错,绝不能退化成用空密钥签名(那样谁都能自己造票据)。
func TestShareTicketRefusesEmptyKey(t *testing.T) {
old := os.Getenv("FIELD_ENCRYPT_KEY")
os.Unsetenv("FIELD_ENCRYPT_KEY")
defer os.Setenv("FIELD_ENCRYPT_KEY", old)
if _, err := signShareTicket(sharePayload{Id: 1, Uid: "u", Expire: 1}); err == nil {
t.Fatal("缺密钥时本该拒绝签发")
}
}
// 票据里不该出现任何登录凭据的痕迹:它进 URL、会被转发、会进浏览器历史。
func TestShareTicketCarriesOnlyScopedPayload(t *testing.T) {
os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789")
defer os.Unsetenv("FIELD_ENCRYPT_KEY")
ticket, _ := signShareTicket(sharePayload{Id: 7, Uid: "u7", Expire: time.Now().Add(time.Hour).Unix()})
raw, err := base64.RawURLEncoding.DecodeString(ticket[:strings.LastIndex(ticket, ".")])
if err != nil {
t.Fatal(err)
}
m := map[string]any{}
if err = json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if len(m) != 3 {
t.Fatalf("载荷字段数变了(%d),新增字段前先确认它可以公开: %v", len(m), m)
}
for _, k := range []string{"i", "u", "e"} {
if _, ok := m[k]; !ok {
t.Fatalf("载荷缺字段 %s: %v", k, m)
}
}
}

37
apps/services/modules/gateway/share_page.go

@ -0,0 +1,37 @@
package gateway
import (
_ "embed"
"net/http"
"github.com/gin-gonic/gin"
)
// 会议纪要分享落地页。
//
// 为什么嵌进二进制而不是当静态文件下发:
// 这个页面必须跟着 echomeet_getsharedrecord 的协议走,两者版本对不上就是白页。
// 嵌进来意味着「部署了新镜像 = 页面也是新的」,服务器上不需要维护任何文件,
// prod-deploy.sh 也不用多下发一份(confs/*.yaml 那套人工维护的坑已经够多了)。
//
// 上线只剩一步:NPM 上把 share.ymaikj.com 反代到 ym-a11:7100(即本 gateway),申请证书。
//
//go:embed share_page.html
var sharePageHTML []byte
// sharePage 把落地页返回给任意 /share/... 路径。
//
// ⚠️ 必须是 catch-all:真实链接形如 /share/ym.ymaikj.com/ymaikj.com/42/<ticket>,
// 路径里的段是**数据**不是文件路径。用 gin.Static 会去磁盘上找同名文件,一律 404。
// 解析工作全在页面里的 JS 做(它要按 location.pathname 取 serverHost/id/ticket)。
func (this *WSServiceComp) sharePage(c *gin.Context) {
// 不缓存:页面里写死了接口协议,缓存住旧版会在协议变更后变成白页,而用户
// 完全没有手段让它刷新(分享链接是别人发来的,不会有人去硬刷)。
c.Header("Cache-Control", "no-cache, must-revalidate")
// 落地页不该被别的站点嵌进 iframe 里套壳。
c.Header("X-Frame-Options", "SAMEORIGIN")
c.Header("X-Content-Type-Options", "nosniff")
// 别把带票据的完整 URL 经 Referer 泄漏给第三方(音频在 OSS 上,是跨站请求)。
c.Header("Referrer-Policy", "no-referrer")
c.Data(http.StatusOK, "text/html; charset=utf-8", sharePageHTML)
}

316
apps/services/modules/gateway/share_page.html

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>会议纪要</title>
<style>
:root{
--bg:#f5f6f8; --card:#fff; --fg:#1b1d21; --muted:#6b7280; --line:#e5e7eb;
--accent:#2563eb; --accent-soft:#eff4ff; --danger:#b42318;
}
@media (prefers-color-scheme:dark){
:root{
--bg:#101215; --card:#191c21; --fg:#e8eaed; --muted:#9aa1ab; --line:#2a2f37;
--accent:#6ea0ff; --accent-soft:#1b2436; --danger:#ff8a80;
}
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
background:var(--bg); color:var(--fg);
font:15px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif;
-webkit-text-size-adjust:100%;
}
.wrap{max-width:760px;margin:0 auto;padding:16px 16px 40px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:12px}
h1{font-size:19px;line-height:1.4;margin:0 0 8px;word-break:break-word}
.meta{color:var(--muted);font-size:13px;display:flex;flex-wrap:wrap;gap:4px 14px}
audio{width:100%;margin-top:14px}
/* 标签栏:内容少时只显示有内容的那几个 */
.tabs{display:flex;gap:6px;border-bottom:1px solid var(--line);margin:0 0 14px;overflow-x:auto;-webkit-overflow-scrolling:touch}
.tab{
flex:0 0 auto;padding:9px 14px;border:0;background:none;color:var(--muted);
font-size:14px;font-family:inherit;cursor:pointer;border-bottom:2px solid transparent;
}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
.panel[hidden]{display:none}
/* 转写:每句一行,带说话人与时间戳 */
.line{display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--line)}
.line:last-child{border-bottom:0}
.line .who{flex:0 0 auto;min-width:74px;color:var(--accent);font-size:13px;font-weight:600}
.line .t{flex:0 0 auto;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;padding-top:2px}
.line .txt{flex:1 1 auto;word-break:break-word}
/* Markdown 渲染出来的总结 */
.md h1,.md h2,.md h3,.md h4{line-height:1.4;margin:18px 0 8px}
.md h1{font-size:18px} .md h2{font-size:17px} .md h3{font-size:15px} .md h4{font-size:14px}
.md>:first-child{margin-top:0}
.md p{margin:8px 0}
.md ul,.md ol{margin:8px 0;padding-left:22px}
.md li{margin:3px 0}
.md code{background:var(--accent-soft);padding:1px 5px;border-radius:4px;font-size:13px}
.md pre{background:var(--accent-soft);padding:12px;border-radius:8px;overflow-x:auto}
.md pre code{background:none;padding:0}
.md blockquote{margin:8px 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)}
.md hr{border:0;border-top:1px solid var(--line);margin:16px 0}
.md table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14px;display:block;overflow-x:auto}
.md th,.md td{border:1px solid var(--line);padding:7px 10px;text-align:left}
.md th{background:var(--accent-soft);font-weight:600}
.state{text-align:center;padding:52px 16px;color:var(--muted)}
.state strong{display:block;color:var(--fg);font-size:16px;margin-bottom:6px}
.state.err strong{color:var(--danger)}
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;line-height:1.8}
.foot a{color:var(--accent);text-decoration:none}
.spin{width:22px;height:22px;margin:0 auto 12px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:r .8s linear infinite}
@keyframes r{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="wrap" id="root">
<div class="state"><div class="spin"></div>正在打开…</div>
</div>
<script>
(function () {
'use strict';
// ⚠️ 后端域名白名单。serverHost 是从 URL 路径里读出来的,**不校验就等于**:
// 任何人构造一条 /share/<攻击者域名>/.../<票据> 的链接,就能让打开它的人的浏览器
// 向攻击者的服务器发请求(顺带把票据送过去)。既是钓鱼跳板也是票据泄漏。
// 新增环境要在这里加一行,否则那个环境分享出来的链接打不开。
var ALLOWED_HOSTS = [
'ym.ymaikj.com', // 正式
'ym-dev.ymaikj.com' // 阿龙测试
];
var DOWNLOAD_URL = 'https://dl.ymaikj.com'; // 页脚的 App 下载引导
var root = document.getElementById('root');
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function show(html) { root.innerHTML = html; }
function fail(title, detail) {
show('<div class="state err"><strong>' + esc(title) + '</strong>' + esc(detail || '') + '</div>'
+ foot(''));
}
function foot(extra) {
return '<div class="foot">' + (extra ? extra + '<br>' : '')
+ '由 EAIMAR 生成 · <a href="' + DOWNLOAD_URL + '">下载 App</a></div>';
}
// ── 路径解析 ──────────────────────────────────────────────
// /share/{serverHost}/{brand}/{id}/{ticket}
// brand 段是品牌标识,落地页不使用,但保留在路径里以兼容已发出去的链接形状。
// ⚠️ 票据是 base64url,本身不含 '/',所以按 '/' 切是安全的;真要变格式记得回来改这里。
function parsePath() {
var seg = location.pathname.split('/').filter(Boolean); // ['share', host, brand, id, ticket]
if (seg.length < 5 || seg[0] !== 'share') return null;
return { host: seg[1], id: seg[3], ticket: seg.slice(4).join('/') };
}
// ── 极简 Markdown 渲染(总结是 Markdown,见客户端 _exportMarkdownPdf)────
// 不引第三方库:这个页面要在国内手机上、可能很差的网络下秒开,为渲染几段
// 标题和列表去拉一个 CDN 脚本不值得,而且 CDN 被墙时整页就废了。
// ⚠️ 先 esc 再套正则,顺序不能反 —— 内容来自用户录音与模型输出,反了就是 XSS。
function md(src) {
var lines = esc(src).replace(/\r\n?/g, '\n').split('\n');
var out = [], i = 0;
function inline(t) {
return t
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
}
while (i < lines.length) {
var ln = lines[i];
if (/^\s*$/.test(ln)) { i++; continue; }
// 代码块
if (/^```/.test(ln)) {
var buf = []; i++;
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
i++;
out.push('<pre><code>' + buf.join('\n') + '</code></pre>');
continue;
}
// 标题
var h = ln.match(/^(#{1,4})\s+(.*)$/);
if (h) { var lv = h[1].length; out.push('<h' + lv + '>' + inline(h[2]) + '</h' + lv + '>'); i++; continue; }
// 分隔线
if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(ln)) { out.push('<hr>'); i++; continue; }
// 表格:|a|b| 后跟 |---|---|
if (/^\s*\|/.test(ln) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
var cells = function (r) {
return r.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return inline(c.trim()); });
};
var head = cells(ln); i += 2;
var body = [];
while (i < lines.length && /^\s*\|/.test(lines[i])) { body.push(cells(lines[i])); i++; }
out.push('<table><thead><tr>' + head.map(function (c) { return '<th>' + c + '</th>'; }).join('')
+ '</tr></thead><tbody>'
+ body.map(function (r) { return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>'; }).join('')
+ '</tbody></table>');
continue;
}
// 引用
// ⚠️ 这里匹配的是 &gt; 不是 >:上面已经整段 esc() 过了,原文的 '>' 此刻是 '&gt;'。
// 写成 /^\s*>/ 会永远匹配不上,引用块整个退化成普通段落(而且不报错)。
if (/^\s*&gt;\s?/.test(ln)) {
var q = [];
while (i < lines.length && /^\s*&gt;\s?/.test(lines[i])) { q.push(lines[i].replace(/^\s*&gt;\s?/, '')); i++; }
out.push('<blockquote>' + inline(q.join('<br>')) + '</blockquote>');
continue;
}
// 列表(有序/无序)
var isOl = /^\s*\d+[.)]\s+/.test(ln);
if (isOl || /^\s*[-*+]\s+/.test(ln)) {
var items = [];
var re = isOl ? /^\s*\d+[.)]\s+/ : /^\s*[-*+]\s+/;
while (i < lines.length && re.test(lines[i])) { items.push(inline(lines[i].replace(re, ''))); i++; }
var tag = isOl ? 'ol' : 'ul';
out.push('<' + tag + '>' + items.map(function (t) { return '<li>' + t + '</li>'; }).join('') + '</' + tag + '>');
continue;
}
// 普通段落:连续非空行合成一段
var p = [];
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,4}\s|```|\s*[-*+]\s|\s*\d+[.)]\s|\s*&gt;|\s*\|)/.test(lines[i])) {
p.push(lines[i]); i++;
}
if (p.length) out.push('<p>' + inline(p.join('<br>')) + '</p>');
else i++; // 兜底,避免任何情况下的死循环
}
return out.join('');
}
// ── 转写/译文:JSON 数组 [{speaker,content,starttime,endtime}] ────
// ⚠️ 不能假定它一定是合法 JSON:老记录、导入的外部音频里躺着纯文本。
// 解析失败就原样当文本显示,总比整页报错强。
function renderLines(raw) {
if (!raw) return '';
var arr = null;
try { arr = JSON.parse(raw); } catch (e) { /* 落到纯文本分支 */ }
if (!Array.isArray(arr)) return '<p>' + esc(raw).replace(/\n/g, '<br>') + '</p>';
return arr.map(function (it) {
var who = it.speaker ? '<span class="who">' + esc(it.speaker) + '</span>' : '';
var t = (typeof it.starttime === 'number' && it.starttime > 0)
? '<span class="t">' + hms(Math.floor(it.starttime / 1000)) + '</span>' : '';
return '<div class="line">' + who + t + '<span class="txt">' + esc(it.content || '') + '</span></div>';
}).join('');
}
function hms(sec) {
sec = Math.max(0, sec | 0);
var h = (sec / 3600) | 0, m = ((sec % 3600) / 60) | 0, s = sec % 60;
var p = function (n) { return n < 10 ? '0' + n : '' + n; };
return (h ? h + ':' : '') + p(m) + ':' + p(s);
}
function duration(sec) {
if (!sec) return '';
var m = Math.floor(sec / 60), s = sec % 60;
return m ? (m + ' 分 ' + s + ' 秒') : (s + ' 秒');
}
function ymd(unix) {
if (!unix) return '';
var d = new Date(unix * 1000), p = function (n) { return n < 10 ? '0' + n : n; };
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
}
// ── 渲染 ───────────────────────────────────────────────────
function render(r) {
document.title = r.title || '会议纪要';
var metas = [];
if (r.creationtime) metas.push('<span>' + esc(ymd(r.creationtime)) + '</span>');
if (r.seconds) metas.push('<span>时长 ' + esc(duration(r.seconds)) + '</span>');
var head = '<div class="card"><h1>' + esc(r.title || '未命名会议') + '</h1>'
+ (metas.length ? '<div class="meta">' + metas.join('') + '</div>' : '')
+ (r.audiourl ? '<audio controls preload="none" src="' + esc(r.audiourl) + '"></audio>' : '')
+ '</div>';
// 只放有内容的标签页:空标签点进去一片白,比不显示更让人以为是坏了
var tabs = [];
if (r.summary) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.summary) + '</div>' });
else if (r.overview) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.overview) + '</div>' });
if (r.original) tabs.push({ k: 'org', n: '转写', h: renderLines(r.original) });
if (r.translate) tabs.push({ k: 'trs', n: '译文', h: renderLines(r.translate) });
var body;
if (!tabs.length) {
body = '<div class="card"><div class="state">这条纪要还没有内容<br><span style="font-size:13px">可能仍在转写中,稍后再打开试试</span></div></div>';
} else {
body = '<div class="card">'
+ (tabs.length > 1
? '<div class="tabs" role="tablist">' + tabs.map(function (t, i) {
return '<button class="tab" role="tab" data-k="' + t.k + '" aria-selected="' + (i === 0) + '">' + t.n + '</button>';
}).join('') + '</div>'
: '')
+ tabs.map(function (t, i) {
return '<div class="panel" data-k="' + t.k + '"' + (i === 0 ? '' : ' hidden') + '>' + t.h + '</div>';
}).join('')
+ '</div>';
}
var exp = r.expiretime ? ('本链接于 ' + esc(ymd(r.expiretime)) + ' 失效') : '';
show(head + body + foot(exp));
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (btn) {
btn.addEventListener('click', function () {
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (b) {
b.setAttribute('aria-selected', b === btn);
});
Array.prototype.forEach.call(document.querySelectorAll('.panel'), function (p) {
p.hidden = p.getAttribute('data-k') !== btn.getAttribute('data-k');
});
});
});
}
// ── 主流程 ─────────────────────────────────────────────────
var q = parsePath();
if (!q) { fail('链接不完整', '请确认复制了完整的分享链接'); return; }
if (ALLOWED_HOSTS.indexOf(q.host) < 0) { fail('链接无效', '来源不在允许列表中'); return; }
var xhr = new XMLHttpRequest();
xhr.open('POST', 'https://' + q.host + '/api/home/echomeet_getsharedrecord', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.timeout = 20000;
xhr.onload = function () {
var res;
try { res = JSON.parse(xhr.responseText); } catch (e) {
fail('打开失败', '服务返回了无法识别的内容'); return;
}
// 业务码非 0 一律按「链接失效」呈现:服务端刻意不区分「票据错」「已过期」
// 「记录已删」,这里再自作主张分类只会给出不准确的说法。
// ⚠️ 错误消息的字段名是 msg 不是 message(comm.HttpResult 的 json tag)。
if (!res || res.code !== 0 || !res.data) {
fail('链接已失效', '分享链接可能已过期,或已被创建者删除');
return;
}
render(res.data);
};
xhr.onerror = function () { fail('网络异常', '请检查网络后重试'); };
xhr.ontimeout = function () { fail('网络超时', '请检查网络后重试'); };
xhr.send(JSON.stringify({ ticket: q.ticket }));
})();
</script>
</body>
</html>

2
apps/services/modules/gateway/wservice_comp.go

@ -63,6 +63,8 @@ func (this *WSServiceComp) Init(service core.IService, module core.IModule, comp
this.gin.Static("/avatar", "./avatar") this.gin.Static("/avatar", "./avatar")
this.gin.Static("/console", "./console") this.gin.Static("/console", "./console")
this.gin.Static("/docs", "./docs") this.gin.Static("/docs", "./docs")
// 会议纪要分享落地页(share.ymaikj.com 反代到本端口)。catch-all,理由见 share_page.go。
this.gin.GET("/share/*any", this.sharePage)
this.gin.Any("/api/:param1/:param2", this.api) this.gin.Any("/api/:param1/:param2", this.api)
this.gin.Any("/web/:param1/:param2", this.web) this.gin.Any("/web/:param1/:param2", this.web)
this.gin.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) this.gin.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))

370
apps/services/pb/echomeet_msg.pb.go

@ -2133,6 +2133,296 @@ func (*EchomeetAliCallbackResp) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{40} return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{40}
} }
// ============================== 会议纪要分享 ==============================
//
// 分享链接形如 https://share.ymaikj.com/share/{serverHost}/{brand}/{id}/{ticket}
//
// ⚠️ 2026-09-23 之前客户端把**用户的登录 JWT**直接拼进这个 URL,谁拿到链接就拿到了
//
// 整个账号。现在改成服务端签发的分享票据:只含一条记录、只读、有有效期。
type EchomeetCreateShareLinkReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) 会议记录id
Expiredays int32 `protobuf:"varint,2,opt,name=expiredays,proto3" json:"expiredays"` //@go_tags(`json:"expiredays"`) 有效天数,<=0 用默认(7),上限 30
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EchomeetCreateShareLinkReq) Reset() {
*x = EchomeetCreateShareLinkReq{}
mi := &file_echomeet_echomeet_msg_proto_msgTypes[41]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EchomeetCreateShareLinkReq) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EchomeetCreateShareLinkReq) ProtoMessage() {}
func (x *EchomeetCreateShareLinkReq) ProtoReflect() protoreflect.Message {
mi := &file_echomeet_echomeet_msg_proto_msgTypes[41]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EchomeetCreateShareLinkReq.ProtoReflect.Descriptor instead.
func (*EchomeetCreateShareLinkReq) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{41}
}
func (x *EchomeetCreateShareLinkReq) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *EchomeetCreateShareLinkReq) GetExpiredays() int32 {
if x != nil {
return x.Expiredays
}
return 0
}
type EchomeetCreateShareLinkResp struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ticket string `protobuf:"bytes,1,opt,name=ticket,proto3" json:"ticket"` //@go_tags(`json:"ticket"`) 分享票据,拼进落地页 URL 的最后一段
Expiretime int64 `protobuf:"varint,2,opt,name=expiretime,proto3" json:"expiretime"` //@go_tags(`json:"expiretime"`) 到期时间 unix 秒
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EchomeetCreateShareLinkResp) Reset() {
*x = EchomeetCreateShareLinkResp{}
mi := &file_echomeet_echomeet_msg_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EchomeetCreateShareLinkResp) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EchomeetCreateShareLinkResp) ProtoMessage() {}
func (x *EchomeetCreateShareLinkResp) ProtoReflect() protoreflect.Message {
mi := &file_echomeet_echomeet_msg_proto_msgTypes[42]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EchomeetCreateShareLinkResp.ProtoReflect.Descriptor instead.
func (*EchomeetCreateShareLinkResp) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{42}
}
func (x *EchomeetCreateShareLinkResp) GetTicket() string {
if x != nil {
return x.Ticket
}
return ""
}
func (x *EchomeetCreateShareLinkResp) GetExpiretime() int64 {
if x != nil {
return x.Expiretime
}
return 0
}
// 落地页按票据取只读内容。**免登录**,须在 gateway 的 WhiteList 里。
type EchomeetGetSharedRecordReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ticket string `protobuf:"bytes,1,opt,name=ticket,proto3" json:"ticket"` //@go_tags(`json:"ticket"`) 分享票据
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EchomeetGetSharedRecordReq) Reset() {
*x = EchomeetGetSharedRecordReq{}
mi := &file_echomeet_echomeet_msg_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EchomeetGetSharedRecordReq) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EchomeetGetSharedRecordReq) ProtoMessage() {}
func (x *EchomeetGetSharedRecordReq) ProtoReflect() protoreflect.Message {
mi := &file_echomeet_echomeet_msg_proto_msgTypes[43]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EchomeetGetSharedRecordReq.ProtoReflect.Descriptor instead.
func (*EchomeetGetSharedRecordReq) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{43}
}
func (x *EchomeetGetSharedRecordReq) GetTicket() string {
if x != nil {
return x.Ticket
}
return ""
}
// 只回落地页要展示的字段。刻意不含 uid / filepath / taskid / logid / *_svc_id:
// 这是一个任何人凭链接就能打开的接口,多回一个字段就是多泄一份内部信息。
type EchomeetGetSharedRecordResp struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) 会议记录id
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title"` //@go_tags(`json:"title"`) 标题
Creationtime int64 `protobuf:"varint,3,opt,name=creationtime,proto3" json:"creationtime"` //@go_tags(`json:"creationtime"`) 创建时间 unix 秒
Seconds int32 `protobuf:"varint,4,opt,name=seconds,proto3" json:"seconds"` //@go_tags(`json:"seconds"`) 时长 秒
Audiourl string `protobuf:"bytes,5,opt,name=audiourl,proto3" json:"audiourl"` //@go_tags(`json:"audiourl"`) 音频url
Original string `protobuf:"bytes,6,opt,name=original,proto3" json:"original"` //@go_tags(`json:"original"`) 听写内容
Translate string `protobuf:"bytes,7,opt,name=translate,proto3" json:"translate"` //@go_tags(`json:"translate"`) 翻译
Overview string `protobuf:"bytes,8,opt,name=overview,proto3" json:"overview"` //@go_tags(`json:"overview"`) 短总结
Summary string `protobuf:"bytes,9,opt,name=summary,proto3" json:"summary"` //@go_tags(`json:"summary"`) 总结
Formlanguage string `protobuf:"bytes,10,opt,name=formlanguage,proto3" json:"formlanguage"` //@go_tags(`json:"formlanguage"`) 源语言
Tolanguage string `protobuf:"bytes,11,opt,name=tolanguage,proto3" json:"tolanguage"` //@go_tags(`json:"tolanguage"`) 目标语言
Expiretime int64 `protobuf:"varint,12,opt,name=expiretime,proto3" json:"expiretime"` //@go_tags(`json:"expiretime"`) 本链接到期时间 unix 秒
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EchomeetGetSharedRecordResp) Reset() {
*x = EchomeetGetSharedRecordResp{}
mi := &file_echomeet_echomeet_msg_proto_msgTypes[44]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EchomeetGetSharedRecordResp) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EchomeetGetSharedRecordResp) ProtoMessage() {}
func (x *EchomeetGetSharedRecordResp) ProtoReflect() protoreflect.Message {
mi := &file_echomeet_echomeet_msg_proto_msgTypes[44]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EchomeetGetSharedRecordResp.ProtoReflect.Descriptor instead.
func (*EchomeetGetSharedRecordResp) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{44}
}
func (x *EchomeetGetSharedRecordResp) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *EchomeetGetSharedRecordResp) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetCreationtime() int64 {
if x != nil {
return x.Creationtime
}
return 0
}
func (x *EchomeetGetSharedRecordResp) GetSeconds() int32 {
if x != nil {
return x.Seconds
}
return 0
}
func (x *EchomeetGetSharedRecordResp) GetAudiourl() string {
if x != nil {
return x.Audiourl
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetOriginal() string {
if x != nil {
return x.Original
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetTranslate() string {
if x != nil {
return x.Translate
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetOverview() string {
if x != nil {
return x.Overview
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetSummary() string {
if x != nil {
return x.Summary
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetFormlanguage() string {
if x != nil {
return x.Formlanguage
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetTolanguage() string {
if x != nil {
return x.Tolanguage
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetExpiretime() int64 {
if x != nil {
return x.Expiretime
}
return 0
}
var File_echomeet_echomeet_msg_proto protoreflect.FileDescriptor var File_echomeet_echomeet_msg_proto protoreflect.FileDescriptor
const file_echomeet_echomeet_msg_proto_rawDesc = "" + const file_echomeet_echomeet_msg_proto_rawDesc = "" +
@ -2270,7 +2560,37 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" +
"\aEndTime\x18\x03 \x01(\x03R\aEndTime\x12\x1c\n" + "\aEndTime\x18\x03 \x01(\x03R\aEndTime\x12\x1c\n" +
"\tSpeakerId\x18\x04 \x01(\tR\tSpeakerId\x12\x1c\n" + "\tSpeakerId\x18\x04 \x01(\tR\tSpeakerId\x12\x1c\n" +
"\tChannelId\x18\x05 \x01(\x05R\tChannelId\"\x19\n" + "\tChannelId\x18\x05 \x01(\x05R\tChannelId\"\x19\n" +
"\x17EchomeetAliCallbackRespB\x06Z\x04.;pbb\x06proto3" "\x17EchomeetAliCallbackResp\"L\n" +
"\x1aEchomeetCreateShareLinkReq\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x1e\n" +
"\n" +
"expiredays\x18\x02 \x01(\x05R\n" +
"expiredays\"U\n" +
"\x1bEchomeetCreateShareLinkResp\x12\x16\n" +
"\x06ticket\x18\x01 \x01(\tR\x06ticket\x12\x1e\n" +
"\n" +
"expiretime\x18\x02 \x01(\x03R\n" +
"expiretime\"4\n" +
"\x1aEchomeetGetSharedRecordReq\x12\x16\n" +
"\x06ticket\x18\x01 \x01(\tR\x06ticket\"\xf1\x02\n" +
"\x1bEchomeetGetSharedRecordResp\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x14\n" +
"\x05title\x18\x02 \x01(\tR\x05title\x12\"\n" +
"\fcreationtime\x18\x03 \x01(\x03R\fcreationtime\x12\x18\n" +
"\aseconds\x18\x04 \x01(\x05R\aseconds\x12\x1a\n" +
"\baudiourl\x18\x05 \x01(\tR\baudiourl\x12\x1a\n" +
"\boriginal\x18\x06 \x01(\tR\boriginal\x12\x1c\n" +
"\ttranslate\x18\a \x01(\tR\ttranslate\x12\x1a\n" +
"\boverview\x18\b \x01(\tR\boverview\x12\x18\n" +
"\asummary\x18\t \x01(\tR\asummary\x12\"\n" +
"\fformlanguage\x18\n" +
" \x01(\tR\fformlanguage\x12\x1e\n" +
"\n" +
"tolanguage\x18\v \x01(\tR\n" +
"tolanguage\x12\x1e\n" +
"\n" +
"expiretime\x18\f \x01(\x03R\n" +
"expiretimeB\x06Z\x04.;pbb\x06proto3"
var ( var (
file_echomeet_echomeet_msg_proto_rawDescOnce sync.Once file_echomeet_echomeet_msg_proto_rawDescOnce sync.Once
@ -2284,7 +2604,7 @@ func file_echomeet_echomeet_msg_proto_rawDescGZIP() []byte {
return file_echomeet_echomeet_msg_proto_rawDescData return file_echomeet_echomeet_msg_proto_rawDescData
} }
var file_echomeet_echomeet_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 41) var file_echomeet_echomeet_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 45)
var file_echomeet_echomeet_msg_proto_goTypes = []any{ var file_echomeet_echomeet_msg_proto_goTypes = []any{
(*EchomeetGetTemplatesReq)(nil), // 0: EchomeetGetTemplatesReq (*EchomeetGetTemplatesReq)(nil), // 0: EchomeetGetTemplatesReq
(*EchomeetGetTemplatesResp)(nil), // 1: EchomeetGetTemplatesResp (*EchomeetGetTemplatesResp)(nil), // 1: EchomeetGetTemplatesResp
@ -2327,30 +2647,34 @@ var file_echomeet_echomeet_msg_proto_goTypes = []any{
(*EchomeetAliCallbackResult)(nil), // 38: EchomeetAliCallbackResult (*EchomeetAliCallbackResult)(nil), // 38: EchomeetAliCallbackResult
(*EchomeetAliSentence)(nil), // 39: EchomeetAliSentence (*EchomeetAliSentence)(nil), // 39: EchomeetAliSentence
(*EchomeetAliCallbackResp)(nil), // 40: EchomeetAliCallbackResp (*EchomeetAliCallbackResp)(nil), // 40: EchomeetAliCallbackResp
(*DBEchoMeetTemplate)(nil), // 41: DBEchoMeetTemplate (*EchomeetCreateShareLinkReq)(nil), // 41: EchomeetCreateShareLinkReq
(*DBEchoMeetRecord)(nil), // 42: DBEchoMeetRecord (*EchomeetCreateShareLinkResp)(nil), // 42: EchomeetCreateShareLinkResp
(*ContextStruct)(nil), // 43: ContextStruct (*EchomeetGetSharedRecordReq)(nil), // 43: EchomeetGetSharedRecordReq
(*RecognitionResult)(nil), // 44: RecognitionResult (*EchomeetGetSharedRecordResp)(nil), // 44: EchomeetGetSharedRecordResp
(*AudioInfo)(nil), // 45: AudioInfo (*DBEchoMeetTemplate)(nil), // 45: DBEchoMeetTemplate
(*DBEchoMeetRecord)(nil), // 46: DBEchoMeetRecord
(*ContextStruct)(nil), // 47: ContextStruct
(*RecognitionResult)(nil), // 48: RecognitionResult
(*AudioInfo)(nil), // 49: AudioInfo
} }
var file_echomeet_echomeet_msg_proto_depIdxs = []int32{ var file_echomeet_echomeet_msg_proto_depIdxs = []int32{
41, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate 45, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate
41, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate 45, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate
41, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate 45, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate
41, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate 45, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate
41, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate 45, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate
42, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord 46, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord
42, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord 46, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord
42, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord 46, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord
42, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord 46, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord
43, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct 47, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct
42, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord 46, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord
42, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord 46, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord
44, // 12: EchomeetCallbackReq.result:type_name -> RecognitionResult 48, // 12: EchomeetCallbackReq.result:type_name -> RecognitionResult
45, // 13: EchomeetCallbackReq.audio_info:type_name -> AudioInfo 49, // 13: EchomeetCallbackReq.audio_info:type_name -> AudioInfo
27, // 14: EchomeetGetSummaryServicesResp.services:type_name -> EchomeetSummaryServiceItem 27, // 14: EchomeetGetSummaryServicesResp.services:type_name -> EchomeetSummaryServiceItem
27, // 15: EchomeetGetCapabilitiesResp.summary_services:type_name -> EchomeetSummaryServiceItem 27, // 15: EchomeetGetCapabilitiesResp.summary_services:type_name -> EchomeetSummaryServiceItem
42, // 16: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord 46, // 16: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord
38, // 17: EchomeetAliCallbackReq.Result:type_name -> EchomeetAliCallbackResult 38, // 17: EchomeetAliCallbackReq.Result:type_name -> EchomeetAliCallbackResult
39, // 18: EchomeetAliCallbackResult.Sentences:type_name -> EchomeetAliSentence 39, // 18: EchomeetAliCallbackResult.Sentences:type_name -> EchomeetAliSentence
19, // [19:19] is the sub-list for method output_type 19, // [19:19] is the sub-list for method output_type
@ -2372,7 +2696,7 @@ func file_echomeet_echomeet_msg_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_echomeet_echomeet_msg_proto_rawDesc), len(file_echomeet_echomeet_msg_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_echomeet_echomeet_msg_proto_rawDesc), len(file_echomeet_echomeet_msg_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 41, NumMessages: 45,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },

6
deploy/app/confs/gateway.yaml.example

@ -35,4 +35,10 @@ modules:
- public_info - public_info
- pay_wechatnotify - pay_wechatnotify
- pay_alipaynotify - pay_alipaynotify
# ⚠️ 会议纪要分享落地页(share.ymaikj.com)调它,**打开链接的人本来就没登录**——
# 少这一行返回 code:18 NoLogin,表现是「分享链接打开一片空白」。
# 免登录是安全的:它只认服务端签发的分享票据(HMAC 签名、绑定单条记录、有有效期),
# 实现见 modules/echomeet/api_sharelink.go。签发那一端(echomeet_createsharelink)需要登录,
# **不要**把它也加进白名单。
- echomeet_getsharedrecord
Debug: ${LOG_DEBUG:-false} Debug: ${LOG_DEBUG:-false}

196
docs/会议纪要分享-落地与上线步骤.md

@ -0,0 +1,196 @@
# 会议纪要分享:落地与上线步骤(2026-09-23)
App 里「分享 → 分享链接」发出去的那条链接,之前**从来没有打开过**:域名解析着但没有落地页,
而且链接里带的是用户的完整登录态。这一轮把两件事一起做掉了。
---
## 一、改了什么
### 1. 链接里不再是登录 JWT
| | 之前 | 现在 |
|---|---|---|
| URL 最后一段 | `storage.read("logintoken")`,即 `Authorization` 头里那个 JWT | 服务端签发的**分享票据** |
| 拿到链接能做什么 | 调任何业务接口、看该账号全部纪要、消费算力 | 只能读**这一条**纪要 |
| 失效手段 | 只能改密码 | 默认 7 天自动过期 |
票据是自包含的 HMAC 签名串,不落库:
```
<payload>.<sig>
payload = base64url(JSON{"i":记录id,"u":owner uid,"e":到期unix秒})
sig = base64url(HMAC-SHA256(shareKey, payload))
shareKey = HMAC-SHA256(FIELD_ENCRYPT_KEY, "echomeet.share.v1")
```
签名密钥从 `FIELD_ENCRYPT_KEY` **派生**而不是直接用:HMAC 单向,从派生 key 推不回原 key,
两个用途不共用同一份秘密。选它是因为它已经在每台机的 `.env` 里,**上线不需要新增任何配置项**。
⚠️ 代价:票据签发后**无法单独撤销**,只能等过期或换 `FIELD_ENCRYPT_KEY`(那会让全部票据失效)。
这是刻意接受的取舍——要撤销能力时补一张吊销表即可,票据格式不用变。
### 2. 新增两个接口(`modules/echomeet/api_sharelink.go`)
| 接口 | 登录 | 说明 |
|---|---|---|
| `echomeet_createsharelink` | **要** | 入参 `{id, expiredays}`,回 `{ticket, expiretime}`。签发前先校验这条记录是本人的 |
| `echomeet_getsharedrecord` | **不要** | 入参 `{ticket}`,回落地页要展示的只读字段 |
第二个**必须在 gateway 的 `WhiteList` 里**——打开链接的人本来就没登录。已加进
[gateway.yaml.example](../deploy/app/confs/gateway.yaml.example),⚠️ **各机上手工维护的
`confs/gateway.yaml` 要同步加这一行**,漏了就是 `code:18`,表现为「分享链接打开一片空白」。
响应刻意不含 `uid` / `filepath` / `taskid` / `logid` / `*_svc_id`:这是任何人凭链接就能
打开的接口,多回一个字段就是多泄一份内部信息。
### 3. 落地页嵌进了 gateway 二进制
`modules/gateway/share_page.html` 经 `go:embed` 打进二进制,路由 `GET /share/*any`。
为什么不做成静态文件下发:页面必须跟 `echomeet_getsharedrecord` 的协议同步,
版本对不上就是白页。嵌进来意味着「部署了新镜像 = 页面也是新的」,服务器上不用维护任何文件。
⚠️ 路由必须是 catch-all。真实链接形如 `/share/ym.ymaikj.com/ymaikj.com/42/<ticket>`,
路径里的段是**数据**不是文件路径,用 `gin.Static` 会去磁盘找同名文件,一律 404。
---
## 二、上线状态(2026-09-23 实测)
| 步骤 | 状态 |
|---|---|
| 镜像 `starpivot-app:0.2.5` 构建并推送 | ✅ |
| 正式机 `ym-a11` 部署(0.2.4 → 0.2.5) | ✅ 容器 healthy,日志无 panic |
| `confs/gateway.yaml` 加 `echomeet_getsharedrecord` 白名单 | ✅ 已改并重启(备份 `gateway.yaml.bak-20260923`) |
| `confs/home.yaml` 的 `AppStoreBundleID` 改正 | ✅ 顺带修掉(备份 `home.yaml.bak-20260923`) |
| **NPM 加 `share.ymaikj.com` 反代 + 证书** | ❌ **待办,见下** |
容器内实测:
```
/share/... → HTTP 200, 15042 bytes, <title>会议纪要</title>
echomeet_getsharedrecord + 假票据 → {"code":20,"msg":"分享链接无效或已过期"} ← 白名单生效
echomeet_createsharelink 未登录 → {"code":18,"msg":"NoLogin"} ← 路由已注册
```
### 只剩一步:NPM 反代
`share.ymaikj.com` 的 DNS 已指向 `47.116.104.181`,但 NPM 上还没有它的 proxy host
(现有四条:`console` / `deepglass` / `mcp` / `ym`)。这一步要登录 NPM 后台操作:
1. **Proxy Hosts → Add Proxy Host**
- Domain Names: `share.ymaikj.com`
- Scheme `http`,Forward Hostname `ym-a11`,Forward Port `7100`
- 打开 Block Common Exploits
2. **SSL 标签页 → Request a new SSL Certificate**
- ⚠️ 现有证书都是单域名的(`npm-1`…`npm-mcp`,**没有通配符**),所以必须给
`share.ymaikj.com` 单独签一张
- 勾 Force SSL、HTTP/2
- **不要开 "Use a DNS Challenge"**,保持默认的 HTTP-01 即可
⚠️ **这台机上签证书用的是 HTTP-01(webroot),不是 DNS challenge。**
2026-09-23 实测:5 张现存证书的 `/etc/letsencrypt/renewal/npm-*.conf` 全部是
`authenticator = webroot`,容器里也没装 `certbot-dns-aliyun`
(`certbot plugins` 里搜不到 dns- 开头的任何东西)。最近那张 `mcp.ymaikj.com`
就是这么签成的。所以**不需要**阿里云 AK/SK、不需要填 Propagation Seconds、
不需要先装插件。[运维-容器断网与免费证书排查.md](运维-容器断网与免费证书排查.md)
那篇写的 DNS challenge 是给灵谱那台机的,别照搬到这里。
HTTP-01 的唯一前置是 **80 端口从公网能打到这台机**,且该域名的 DNS 已生效——
两条都已满足(NPM 监听 `0.0.0.0:80`,`share.ymaikj.com` 解析到 47.116.104.181)。
⚠️ 顺序不能反:**必须先保存 proxy host、再去签证书**。证书验证时 Let's Encrypt 会来访
`http://share.ymaikj.com/.well-known/acme-challenge/...`,没有这条 proxy host,
nginx 上就没有匹配该 `server_name` 的 80 端口配置,验证必然失败。
配完验收:
```bash
curl -sI https://share.ymaikj.com/share/ym.ymaikj.com/ymaikj.com/1/x | head -1 # 期望 200
```
然后 App 里打开一条纪要 → 分享 → 分享链接 → 发给自己 → 点开。
---
## 三、原始上线步骤(留档)
### 1. 部署新镜像
```bash
cd deploy/app
DEPLOY_KEY=$HOME/Documents/keys/loginscre.pem ./prod-build.sh <版本号>
DEPLOY_KEY=$HOME/Documents/keys/loginscre.pem ./prod-deploy.sh ym along <版本号>
```
### 2. 补 gateway 白名单(**容易漏,漏了整条链路不通**)
正式机 `47.116.104.181` 上 `/home/work/ym-a11/confs/gateway.yaml`,在 `WhiteList` 下加:
```yaml
- echomeet_getsharedrecord
```
改完 `docker restart ym-a11` 即可——`confs/` 是以卷挂进容器的,进程重启就会重读 yaml。
⚠️ 「必须重建容器」那条规矩只适用于改 **`.env`**(环境变量在容器创建时注入,restart 不会重读)。
### 3. NPM 加一条反代
`share.ymaikj.com` → `ym-a11:7100`,申请证书。
证书走阿里云 DNS challenge,踩坑记录见 [运维-容器断网与免费证书排查.md](运维-容器断网与免费证书排查.md):
`Propagation Seconds` 必须填整数(如 `120`),插件要先手动
`docker exec <NPM> pip install -i https://mirrors.aliyun.com/pypi/simple certbot-dns-aliyun`。
### 4. 验收
```bash
# ① 落地页出得来(应返回 200 + HTML)
curl -sI https://share.ymaikj.com/share/ym.ymaikj.com/ymaikj.com/1/x | head -3
# ② 免登录接口通了(应返回 code:20 链接无效,而**不是** code:18 NoLogin)
curl -sX POST https://ym.ymaikj.com/api/home/echomeet_getsharedrecord \
-H 'Content-Type: application/json' -d '{"ticket":"bad"}'
```
拿到 `code:18` = 第 2 步的白名单没生效。拿到 `code:20` = 通了(票据确实是假的)。
③ 真机:App 里打开一条纪要 → 分享 → 分享链接 → 发给自己 → 点开。
### 5. 客户端要发版
链接格式变了(最后一段 token → ticket),**旧包分享出来的链接落地页打不开**
(它会拿旧 JWT 当票据送上去,签名校验必然失败,显示「链接已失效」)。
这不构成兼容问题:分享功能此前从来没有真正可用过,外面不存在能打开的老链接。
---
## 四、落地页里两处不能动的地方
### 后端域名白名单
```js
var ALLOWED_HOSTS = ['ym.ymaikj.com', 'ym-dev.ymaikj.com'];
```
`serverHost` 是从 URL 路径里读出来的。**不校验就等于**:任何人构造一条
`/share/<攻击者域名>/.../<票据>` 的链接,就能让打开它的人的浏览器向攻击者的服务器发请求,
顺带把票据送过去。既是钓鱼跳板也是票据泄漏。新增环境要在这里加一行。
### 先转义再套 Markdown 正则
总结是 Markdown(与客户端 `_exportMarkdownPdf` 同源),页面里有个极简渲染器。
它**先 `esc()` 再套正则**——内容来自用户录音与模型输出,顺序反了就是 XSS。
页面刻意不引第三方 Markdown 库:这页要在国内手机、可能很差的网络下秒开,
为渲染几段标题列表去拉 CDN 不值得,而且 CDN 被墙时整页就废了。
---
## 五、还没做
- **票据吊销**:见上文取舍。
- **国际化**:落地页是中文的。分享出去的对象大概率与创建者同语言,先不做。
- **`web.ymaikj.com`**:App 里「EAIMAR Web」入口指向它,`apps/web` 仍是空壳,点开打不开。
与本次分享链路无关,但它俩是同一类「域名解析了、东西没做」的欠账。
Loading…
Cancel
Save