@lark-apaas/coding-steering 0.1.38 → 0.1.39-alpha.20260824122813
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +19 -28
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +2 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +190 -1181
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +10 -229
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -18
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +40 -643
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -19
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +2 -4
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +1 -308
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +0 -14
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +7 -157
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +2 -1598
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +4 -151
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
- package/steering/design-html/skills/preflight/scripts/probe.sh +0 -0
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +0 -11
- package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +4 -67
- package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +1 -0
- package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +0 -75
- package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +7 -5
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +0 -556
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +0 -479
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +0 -400
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +0 -12
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
"""Regression tests for inherited layout text and model-decided text roles."""
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
-
import re
|
|
6
5
|
import sys
|
|
7
6
|
import tempfile
|
|
8
7
|
import unittest
|
|
@@ -143,16 +142,16 @@ class TextRoleContractTest(unittest.TestCase):
|
|
|
143
142
|
with open(path, encoding='utf-8') as stream:
|
|
144
143
|
draft = stream.read()
|
|
145
144
|
|
|
146
|
-
self.assertIn('
|
|
145
|
+
self.assertIn('text_roles:', draft)
|
|
147
146
|
self.assertIn(
|
|
148
|
-
'
|
|
147
|
+
'layout-1-text-1: TODO文本角色',
|
|
149
148
|
draft,
|
|
150
149
|
)
|
|
151
150
|
self.assertEqual(draft.count('box: [120, 80, 840, 120]'), 1)
|
|
152
151
|
|
|
153
152
|
decided = draft.replace(
|
|
154
|
-
'
|
|
155
|
-
'
|
|
153
|
+
'layout-1-text-1: TODO文本角色',
|
|
154
|
+
'layout-1-text-1: title',
|
|
156
155
|
)
|
|
157
156
|
layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
|
|
158
157
|
|
|
@@ -164,152 +163,6 @@ class TextRoleContractTest(unittest.TestCase):
|
|
|
164
163
|
)
|
|
165
164
|
self.assertIn('css: "font-size: 48px; color: #C41230"', layouts_md)
|
|
166
165
|
|
|
167
|
-
def test_header_role_uses_a_v2_slot_type(self):
|
|
168
|
-
draft = """names:
|
|
169
|
-
layout-1: 内容页
|
|
170
|
-
roles:
|
|
171
|
-
layout-1: content
|
|
172
|
-
text_roles:
|
|
173
|
-
layout-1-text-1: header
|
|
174
|
-
layouts:
|
|
175
|
-
layout-1:
|
|
176
|
-
slots:
|
|
177
|
-
# text-role: layout-1-text-1
|
|
178
|
-
- {role: body, box: [120, 80, 840, 120], type: body}
|
|
179
|
-
confidence: high
|
|
180
|
-
"""
|
|
181
|
-
|
|
182
|
-
layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
|
|
183
|
-
|
|
184
|
-
self.assertIn(
|
|
185
|
-
'role: header, box: [120, 80, 840, 120], type: body',
|
|
186
|
-
layouts_md,
|
|
187
|
-
)
|
|
188
|
-
self.assertNotIn('type: header', layouts_md)
|
|
189
|
-
|
|
190
|
-
def test_decided_layout_role_replaces_the_draft_default(self):
|
|
191
|
-
draft = """names:
|
|
192
|
-
layout-1: 末页
|
|
193
|
-
roles:
|
|
194
|
-
layout-1: closing
|
|
195
|
-
layouts:
|
|
196
|
-
layout-1:
|
|
197
|
-
role: content
|
|
198
|
-
slots:
|
|
199
|
-
- {role: title, box: [120, 80, 840, 120], type: title}
|
|
200
|
-
confidence: high
|
|
201
|
-
"""
|
|
202
|
-
|
|
203
|
-
layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
|
|
204
|
-
|
|
205
|
-
self.assertIn(' role: closing', layouts_md)
|
|
206
|
-
self.assertNotIn(' role: content', layouts_md)
|
|
207
|
-
self.assertEqual(layouts_md.count(' role:'), 1)
|
|
208
|
-
|
|
209
|
-
def test_last_slide_is_kept_as_a_role_candidate_when_layout_limit_is_full(self):
|
|
210
|
-
shapes, slides = [], []
|
|
211
|
-
for index in range(1, 11):
|
|
212
|
-
slide_part = 'ppt/slides/slide%d.xml' % index
|
|
213
|
-
shapes.append(text_shape(
|
|
214
|
-
slide_part,
|
|
215
|
-
'slide',
|
|
216
|
-
str(index),
|
|
217
|
-
{'x': 120, 'y': 80, 'w': 840, 'h': 120},
|
|
218
|
-
'Slide %d' % index,
|
|
219
|
-
{'type': 'body', 'idx': str(index)},
|
|
220
|
-
))
|
|
221
|
-
slides.append({
|
|
222
|
-
'part': slide_part,
|
|
223
|
-
'layout': 'ppt/slideLayouts/slideLayout1.xml',
|
|
224
|
-
'background': '#%02x0000' % index,
|
|
225
|
-
})
|
|
226
|
-
data = {
|
|
227
|
-
'canvas': {'px': [1920, 1080]},
|
|
228
|
-
'form_hint': {'form': 0},
|
|
229
|
-
'slides': slides,
|
|
230
|
-
'background_composites': {},
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
with tempfile.TemporaryDirectory() as output_dir:
|
|
234
|
-
os.makedirs(os.path.join(output_dir, 'ref'))
|
|
235
|
-
with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
|
|
236
|
-
encoding='utf-8') as stream:
|
|
237
|
-
json.dump({'shapes': shapes}, stream)
|
|
238
|
-
archetypes, _, leftover = draft_layouts(data, output_dir)
|
|
239
|
-
emit_layouts(archetypes, output_dir)
|
|
240
|
-
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
241
|
-
draft = stream.read()
|
|
242
|
-
|
|
243
|
-
last_archetype = next(
|
|
244
|
-
archetype for archetype in archetypes if archetype['pages'] == [10]
|
|
245
|
-
)
|
|
246
|
-
self.assertTrue(last_archetype['_last_page_candidate'])
|
|
247
|
-
self.assertNotIn(10, leftover)
|
|
248
|
-
self.assertIn(
|
|
249
|
-
'代表页 %s,共 1 页;' % last_archetype['rep'],
|
|
250
|
-
draft,
|
|
251
|
-
)
|
|
252
|
-
self.assertIn('末页候选,结合样张判断 closing 或实际角色', draft)
|
|
253
|
-
decided = draft.replace(
|
|
254
|
-
'%s: TODO角色' % last_archetype['name'],
|
|
255
|
-
'%s: closing' % last_archetype['name'],
|
|
256
|
-
)
|
|
257
|
-
layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
|
|
258
|
-
self.assertRegex(
|
|
259
|
-
layouts_md,
|
|
260
|
-
r' %s:\n(?: .*\n)*? role: closing\n'
|
|
261
|
-
% last_archetype['name'],
|
|
262
|
-
)
|
|
263
|
-
self.assertRegex(
|
|
264
|
-
layouts_md,
|
|
265
|
-
r' %s:\n(?: .*\n)*? - \{role: body, box: \[120, 80, 840, 120\]'
|
|
266
|
-
% last_archetype['name'],
|
|
267
|
-
)
|
|
268
|
-
self.assertIn(
|
|
269
|
-
' %s:' % last_archetype['name'],
|
|
270
|
-
layouts_md,
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
def test_template_layout_shape_without_ph_key_does_not_crash(self):
|
|
274
|
-
# form=3 模板里,版式层可能有「带 box、带文字、但没有 ph 键」的普通形状
|
|
275
|
-
# (非占位符的文本/装饰)。layouts_from_template 的入口筛选是
|
|
276
|
-
# `s.get('ph') or shape_text(s)`——有文字就放进来,随后按 ph 判类型时若用
|
|
277
|
-
# s['ph'] 直接下标就会 KeyError: 'ph',整份抽取在草案阶段崩掉(EXTRACT_PARTIAL)。
|
|
278
|
-
layout_part = 'ppt/slideLayouts/slideLayout1.xml'
|
|
279
|
-
shape = {
|
|
280
|
-
'part': layout_part,
|
|
281
|
-
'layer': 'layout',
|
|
282
|
-
'id': '7',
|
|
283
|
-
'kind': 'sp',
|
|
284
|
-
'name': '页脚文字',
|
|
285
|
-
# 关键:没有 'ph' 键
|
|
286
|
-
'box': {'x': 100, 'y': 980, 'w': 800, 'h': 60},
|
|
287
|
-
'text': {
|
|
288
|
-
'bodyPr': {},
|
|
289
|
-
'lstStyle': {'lvl1pPr': {'sz_px': 20}},
|
|
290
|
-
'paragraphs': [{'runs': [{'text': '内部资料'}]}],
|
|
291
|
-
},
|
|
292
|
-
}
|
|
293
|
-
data = {
|
|
294
|
-
'canvas': {'px': [1920, 1080]},
|
|
295
|
-
'form_hint': {'form': 3},
|
|
296
|
-
'layouts': [{'part': layout_part}],
|
|
297
|
-
'slides': [{'part': 'ppt/slides/slide1.xml', 'layout': layout_part,
|
|
298
|
-
'background': None}],
|
|
299
|
-
'background_composites': {},
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
with tempfile.TemporaryDirectory() as output_dir:
|
|
303
|
-
os.makedirs(os.path.join(output_dir, 'ref'))
|
|
304
|
-
with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
|
|
305
|
-
encoding='utf-8') as stream:
|
|
306
|
-
json.dump({'shapes': [shape]}, stream)
|
|
307
|
-
# 修复前这里抛 KeyError: 'ph'(draft.py 用 s['ph'] 直接下标),
|
|
308
|
-
# 抽取在草案阶段崩掉、退成 EXTRACT_PARTIAL。修复后应正常返回。
|
|
309
|
-
archetypes, pages, leftover = draft_layouts(data, output_dir)
|
|
310
|
-
|
|
311
|
-
self.assertIsInstance(archetypes, list)
|
|
312
|
-
|
|
313
166
|
|
|
314
167
|
if __name__ == '__main__':
|
|
315
168
|
unittest.main()
|
|
@@ -133,7 +133,7 @@ layouts:
|
|
|
133
133
|
|
|
134
134
|
- **`background` 三形态**:`<asset-id>` / `{<theme>: <asset-id>}` / `{color: <colors-token>}`(`color` 是保留键,主题名禁止叫 color)。`asset` 两形态:`<asset-id>` / `{<theme>: <asset-id>}`。
|
|
135
135
|
- **背景安全扩展**:有真实背景图的 archetype 建议写 `text_safe: [x,y,w,h]`、`avoid: [{box: [x,y,w,h], reason: "..."}]`、`pairing_rule: "..."`。这些是消费约束,不参与封闭枚举;用于避免标题、正文、图表、卡片、表格、时间线及其容器外接矩形覆盖背景视觉主体、强光斑或深色透明区;透明容器也不能跨进禁放区。
|
|
136
|
-
- **流式页型**:内容长度会变化的内容页可用 `flow.regions` 表达纵向区带。`stack` 表达单列顺序,`grid` 表达并列列组,`free` 中的 item 必须带 `box`,用于 logo、页码、页眉和页脚等固定锚点。并列卡片可在 `grid.items` 中使用一层 `{role: group, css, gap, items}`:group 的 `css` 是卡片容器样式,内部 `items` 按顺序排布;不继续嵌套 group
|
|
136
|
+
- **流式页型**:内容长度会变化的内容页可用 `flow.regions` 表达纵向区带。`stack` 表达单列顺序,`grid` 表达并列列组,`free` 中的 item 必须带 `box`,用于 logo、页码、页眉和页脚等固定锚点。并列卡片可在 `grid.items` 中使用一层 `{role: group, css, gap, items}`:group 的 `css` 是卡片容器样式,内部 `items` 按顺序排布;不继续嵌套 group。纵向位置与留白由消费模型结合实际内容决定,不把样张的 `y` 坐标当作流式硬约束。
|
|
137
137
|
- **`decor`(可选)**:这一页无文字的图形骨架——图标托底的圆、卡片、分隔线。每条 `{box, geom, css}`:`box` 定位,`css` 是可直接写进 style 的声明串,`geom` 取源形状的 prst(`ellipse` 另加 `border-radius: 50%`)。圆角以每条 `css` 为准,没有 `border-radius` 就按 `0`;不得因 `geom: roundRect` 自行补圆角,因为 OOXML 的 roundRect 可以有零圆角调节点。层级在背景之上、`slots` 之下;带 `asset` 的槽落在 decor 之上是版式本意,不算重叠。
|
|
138
138
|
- **slot 样式契约**:`box` 只承载 `[x,y,w,h]` 几何;可渲染属性统一放进 `css`,并可直接写入 HTML `style`。PPTX `bodyPr.insets_px` 转成 `box-sizing: border-box; padding: ...`,字号/字重/颜色/水平与垂直对齐/行高/字距/旋转分别转成标准 CSS。禁止在 slot 中输出 `size` / `weight` / `color` / `align` / `valign` / `insets_px` 等旧字段。
|
|
139
139
|
- **文本角色判断**:脚本把实例页及其引用版式中的现有文本槽、几何和 CSS 完整写入草案;`text_roles` 只供模型把这些槽判断为 `title | subtitle | header | footer | body`,不控制槽位去留。判断不清时用 `body`,不归纳模板中不存在的标题、页眉或页脚。
|
|
File without changes
|
|
@@ -446,7 +446,6 @@ await db.select().from(users).where(eq(users.adminUser, userId));
|
|
|
446
446
|
async submitPublic(@Body() dto) { ... }
|
|
447
447
|
```
|
|
448
448
|
8. **OpenAPI 文档同步**:改动 `*.openapi.controller.ts` 或其引用的 interface / service 返回值 / schema 字段时,加载 `openapi-guide` skill,同步更新 `docs/openapi.json`
|
|
449
|
-
9. **服务端查用户信息(CRITICAL)**:**必须** `import { AuthNPaasService } from '@lark-apaas/fullstack-nestjs-core'` 并用 `listUsersByIds`(`PlatformModule.forRoot()` 已注册 global 的 `AuthNPaasModule`,**不要**加 `providers`、**不要**加直接依赖)。**禁止**复用应用内已有的用户 service、裸调平台接口、从库表 join 人名头像、依赖 `req.userContext.userId`(`/openapi` 下恒为空)、`try/catch` 吞掉 SDK 抛的平台错误、用 `getBatchLarkUserIds` 判断用户是否存在(外部用户无 employeeId)、拿不到的字段用 `''` 占位。搜人 / 搜部门 / 搜群、手机号职位工号上级服务端**拿不到**,走飞书开放平台。动手前加载 `contacts-service` skill 第六节
|
|
450
449
|
|
|
451
450
|
## 异常处理
|
|
452
451
|
|
|
@@ -514,8 +513,6 @@ async createArticle(@Req() req: Request, @Body() dto: CreateArticleDto) {
|
|
|
514
513
|
| 语音识别、音频转文字、STT | AI语音转文字 |
|
|
515
514
|
| 插件实例、PluginInstance、Capability | 通用插件调用 |
|
|
516
515
|
|
|
517
|
-
> ⚠️ **语义检索 / 相似推荐场景例外**:若需求是对**应用自有数据**做语义检索、向量检索、相似推荐、相关推荐、按内容找相似,**不要**用上表的「AI搜索总结 / AI文本转JSON / AI智能生文」等通用插件替代,必须召回 `/semantic-search`。上表的 AI 搜索/文本插件用于公网搜索、文本生成、结构化抽取,**不具备对自有数据库的向量检索能力**。
|
|
518
|
-
|
|
519
516
|
### 关键区分:多维表格 vs 数据库表
|
|
520
517
|
|
|
521
518
|
| 场景 | 判断 | 操作 |
|
|
@@ -618,14 +615,6 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
618
615
|
|
|
619
616
|
禁止未调用 Skill 直接编写表单/图表/表格代码。
|
|
620
617
|
|
|
621
|
-
### 检索 Skill 召回规则(强制执行)
|
|
622
|
-
|
|
623
|
-
| 场景 | Skill | 说明 |
|
|
624
|
-
|------|-------|------|
|
|
625
|
-
| 语义检索、向量检索、相似推荐、相关推荐、相似内容、按内容找相似、猜你喜欢 | `/semantic-search` | 对应用自有数据库的数据做向量检索 / 相似召回 |
|
|
626
|
-
|
|
627
|
-
禁止未调用 Skill 直接用「AI搜索总结 / AI文本转JSON / AI智能生文」等通用插件实现对自有数据的语义检索。
|
|
628
|
-
|
|
629
618
|
### 组件使用规范
|
|
630
619
|
|
|
631
620
|
- 优先使用 `client/src/components` 下已有组件(Card/Button/Badge 等)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: contacts-service
|
|
3
|
-
description: "Use when 搜人/选人/人员选择器/部门选择器/群组选择、获取或展示用户与部门信息、把用户/部门/群组 ID 传给飞书内置插件或飞书开放平台 API
|
|
3
|
+
description: "Use when 搜人/选人/人员选择器/部门选择器/群组选择、获取或展示用户与部门信息、把用户/部门/群组 ID 传给飞书内置插件或飞书开放平台 API、人员字段入库或导出。统一妙搭 ID 体系(miaoda_user_id / employee_id / open_department_id / open_chat_id;lark_* 内部 ID 禁用)、字段获取分级与权限引导。触发词:搜人, 选人, 人员选择器, 部门选择器, 群组选择, UserSelect, DepartmentSelect, ChatSelect, 获取用户信息, 用户字段, 部门信息, 群组信息, employee_id, open_id, union_id, open_department_id, open_chat_id, miaoda_user_id, lark_id, 飞书用户ID, 飞书部门ID, 外部用户, 工号, 手机号, 直属上级, 人员入库, 人员导出, id_convert, 通讯录, 选负责人, 选审批人, 传插件, 传开放平台, 选中的人传给, 把选中的人"
|
|
4
4
|
steering: true
|
|
5
5
|
steering-topic: contacts_service
|
|
6
6
|
match-template-name: nestjs-react-fullstack
|
|
@@ -10,9 +10,7 @@ match-template-name: nestjs-react-fullstack
|
|
|
10
10
|
|
|
11
11
|
> 本 Skill 定义妙搭 Agent 处理**用户 / 部门 / 群组信息**的完整规范:ID 体系、接口返回结构、ID 选择决策、字段获取分级与权限引导。
|
|
12
12
|
>
|
|
13
|
-
> **相关 skill**:当前登录用户("我是谁"、useCurrentUserProfile、req.userContext)见 [`user-identity`](../user-identity/SKILL.md);选择器/展示组件的 props 与用法见 [`client-builtins-user-service`](../client-builtins-user-service/SKILL.md);飞书原生接口(通讯录 API、id_convert)见 [`feishu`](../../../feishu/SKILL.md)
|
|
14
|
-
>
|
|
15
|
-
> **⚠️ 先分流**:一~五节讲的是**浏览器里**的搜人 / 选择器口径。代码跑在**产物服务端**(NestJS service / controller,尤其 `*.openapi.controller.ts`)时,能力边界完全不同——直接看[第六节](#六服务端--openapi-态怎么查通讯录)。
|
|
13
|
+
> **相关 skill**:当前登录用户("我是谁"、useCurrentUserProfile、req.userContext)见 [`user-identity`](../user-identity/SKILL.md);选择器/展示组件的 props 与用法见 [`client-builtins-user-service`](../client-builtins-user-service/SKILL.md);飞书原生接口(通讯录 API、id_convert)见 [`feishu`](../../../feishu/SKILL.md)。
|
|
16
14
|
|
|
17
15
|
## 命名约定(必读)
|
|
18
16
|
|
|
@@ -200,68 +198,7 @@ match-template-name: nestjs-react-fullstack
|
|
|
200
198
|
|
|
201
199
|
---
|
|
202
200
|
|
|
203
|
-
##
|
|
204
|
-
|
|
205
|
-
### 6.1 为什么服务端不一样
|
|
206
|
-
|
|
207
|
-
前端靠 cookie,网关校验登录态后注入 `x-larkgw-suda-webuser`;OpenAPI 请求**没有 cookie**,`req.userContext.userId` 恒为空。所以判据很简单:**「返回什么取决于调用者是谁」的能力在 OpenAPI 态一律不可用,「给定 ID 取数据」的可用**。
|
|
208
|
-
|
|
209
|
-
### 6.2 服务端能做什么
|
|
210
|
-
|
|
211
|
-
> **强制规则**:服务端(尤其 `/openapi`)查用户信息,**唯一允许的实现是 `AuthNPaasService.listUsersByIds`**(禁止项见 6.4.0)。`import` 不到说明 core 版本没跟上,**报告阻塞**,不要加直接依赖绕过、也不要自实现。
|
|
212
|
-
|
|
213
|
-
**按 ID 批量查人** —— 从项目统一入口 `@lark-apaas/fullstack-nestjs-core` 取 `AuthNPaasService`。`PlatformModule.forRoot()` 已注册 global 的 `AuthNPaasModule`,**不需要**在业务 module 加 `providers`,也**不需要**加直接依赖:
|
|
214
|
-
|
|
215
|
-
```ts
|
|
216
|
-
import { AuthNPaasService } from '@lark-apaas/fullstack-nestjs-core';
|
|
217
|
-
|
|
218
|
-
@Injectable()
|
|
219
|
-
export class TicketService {
|
|
220
|
-
constructor(private readonly authn: AuthNPaasService) {}
|
|
221
|
-
|
|
222
|
-
async attachAssignees(ids: string[]) {
|
|
223
|
-
// 返回与入参等长同序,未命中的位置是 null
|
|
224
|
-
const users = await this.authn.listUsersByIds(ids);
|
|
225
|
-
return users.map((u, i) => ({
|
|
226
|
-
miaoda_user_id: ids[i],
|
|
227
|
-
name: u?.name?.zh_cn ?? u?.name?.en_us ?? '', // name 是 I18nText,不是字符串
|
|
228
|
-
avatar: u?.avatar?.image?.large ?? '', // 头像 URL 在 image.large 上
|
|
229
|
-
}));
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
- 入参是 **miaoda_user_id**,单次最多 **100** 个;返回 `(MiaodaUserInfo | null)[]`,与入参等长同序,不丢项也不错位
|
|
235
|
-
- 只有 `miaodaUserID` / `name` / `avatar` 三个字段——**没有**邮箱、手机号、工号、部门
|
|
236
|
-
- 平台出错抛 `HttpException`(502),**不会**静默返回空数组——**不要 try/catch 吞掉**,「平台挂了」和「查无此人」必须能区分
|
|
237
|
-
- 服务端拿不到的字段(部门 / 邮箱 / 工号 / 职位)**从响应类型里删掉**,别用 `''` 占位骗调用方;**并告诉用户**这些字段要走飞书开放平台通讯录 API(见 6.3),不是没实现
|
|
238
|
-
|
|
239
|
-
**妙搭 ↔ 飞书 ID 转换** —— 同一个 `AuthNPaasService` 上的 `getBatchLarkUserIds()` / `getBatchMiaodaUserIds()`(双向批量,不依赖调用者身份)。**不要拿它判断用户是否存在**——外部用户本来就没有 employeeId,会被误判成「用户不存在」。判存在性用 `listUsersByIds` 的返回是不是 `null`。
|
|
240
|
-
|
|
241
|
-
### 6.3 服务端做不到什么,改走哪里
|
|
242
|
-
|
|
243
|
-
| 想做的事 | 服务端 | 替代路径 |
|
|
244
|
-
|---|:---:|---|
|
|
245
|
-
| 按 ID 批量查人 | ✅ | `AuthNPaasService.listUsersByIds` |
|
|
246
|
-
| 妙搭 ↔ 飞书 ID 转换 | ✅ | `AuthNPaasService.getBatchLarkUserIds` / `getBatchMiaodaUserIds` |
|
|
247
|
-
| 搜人 / 搜部门 / 搜群(按关键词) | ❌ | 飞书开放平台通讯录 API(应用身份 + 显式授权的通讯录范围) |
|
|
248
|
-
| 取手机号 / 职位 / 工号 / 直属上级 | ❌ | 同上,字段 ↔ 权限映射见 [`feishu` › contacts.md](../../../feishu/references/contacts.md) |
|
|
249
|
-
| 知道「当前调用者是谁」 | ❌ | OpenAPI 语义上没有当前用户;内部 `/api` 路由才有 `req.userContext.userId` |
|
|
250
|
-
| 按 ID 批量查群 | ❌ | 暂无服务端能力;群信息在前端取,或走开放平台 IM API |
|
|
251
|
-
|
|
252
|
-
**为什么搜索类不给服务端**:结果取决于「谁在搜」,而 OpenAPI 没有登录用户。若平台此时按租户全量返回,外部系统就能通过产物 OpenAPI 把整个企业通讯录搜穿。开放平台那条路的可见范围由租户授权决定,语义上正好匹配。
|
|
253
|
-
|
|
254
|
-
### 6.4 服务端禁止行为
|
|
255
|
-
|
|
256
|
-
0. **禁止用 `AuthNPaasService.listUsersByIds` 之外的任何方式在服务端查用户信息** —— 包括:复用应用内已有的用户 / 用户资料 service、自己写 HTTP 裸调平台通讯录接口、从数据库表 join 出人名头像。这条优先级最高,与下面各条冲突时以本条为准
|
|
257
|
-
1. **禁止在 `*.openapi.controller.ts` 里依赖 `req.userContext.userId`** —— OpenAPI 态恒为空
|
|
258
|
-
2. **禁止用 `getCurrentUserLarkUserId()`** —— 它读 `userId`,取不到只打日志返回 `null`、**不报错**,静默失效。要飞书 ID 一律用 `getBatchLarkUserIds()`
|
|
259
|
-
3. **禁止把 `name` 当字符串**渲染或入库 —— 它是 `I18nText`,直接用会得到 `[object Object]`
|
|
260
|
-
4. **禁止 catch 掉 SDK 抛的平台错误当空结果** —— 「平台挂了」和「查无此人」必须区分开
|
|
261
|
-
|
|
262
|
-
---
|
|
263
|
-
|
|
264
|
-
## 七、禁止行为
|
|
201
|
+
## 六、禁止行为
|
|
265
202
|
1. **禁用兼容字段**:任何调用不得用 lark_id / lark_department_id / lark_chat_id
|
|
266
203
|
2. **禁止混用废弃名**:不得用 userID / suda_user_id / larkUserId 等旧名
|
|
267
204
|
3. **禁止给飞书内置插件传 employee_id**:插件入参必须是 miaoda_user_id
|
|
@@ -271,7 +208,7 @@ export class TicketService {
|
|
|
271
208
|
|
|
272
209
|
---
|
|
273
210
|
|
|
274
|
-
##
|
|
211
|
+
## 七、错误处理
|
|
275
212
|
| 错误场景 | Agent 行为 |
|
|
276
213
|
|---|---|
|
|
277
214
|
| 用户说"获取 user_id" | 追问:妙搭 miaoda_user_id 还是飞书 employee_id? |
|
|
@@ -17,7 +17,6 @@ file-match-pattern:
|
|
|
17
17
|
|------|----------------------|--------------------------|
|
|
18
18
|
| 鉴权 | 写操作加 `@NeedLogin()` | **不加** `@NeedLogin()`,鉴权在网关层通过 API Key 完成 |
|
|
19
19
|
| 用户身份 | `req.userContext.userId` 区分用户 | 统一走系统身份,不依赖 `userId` 做业务区分 |
|
|
20
|
-
| 通讯录 | 前端搜人 / 选择器随便用 | **必须**用 `AuthNPaasService.listUsersByIds`(见下文「用户信息」强制规则),搜索类不可用 |
|
|
21
20
|
| Controller 文件 | `xxx.controller.ts` | `xxx.openapi.controller.ts`,放在同一 module 下 |
|
|
22
21
|
| OpenAPI 文档 | 不需要 | **必须**同步维护 `docs/openapi.json`(见下文) |
|
|
23
22
|
|
|
@@ -60,80 +59,6 @@ findAll(@Req() req: Request) {
|
|
|
60
59
|
}
|
|
61
60
|
```
|
|
62
61
|
|
|
63
|
-
## 用户信息(通讯录)—— 强制规则
|
|
64
|
-
|
|
65
|
-
**`/openapi` 路由下查询用户信息,唯一允许的实现是 `AuthNPaasService.listUsersByIds`。没有例外。**
|
|
66
|
-
|
|
67
|
-
```typescript
|
|
68
|
-
// ✅ 唯一允许的写法。注意 import 自 fullstack-nestjs-core(项目统一入口)
|
|
69
|
-
import { AuthNPaasService } from '@lark-apaas/fullstack-nestjs-core';
|
|
70
|
-
|
|
71
|
-
@Controller('openapi/xxx')
|
|
72
|
-
export class XxxOpenApiController {
|
|
73
|
-
constructor(private readonly authn: AuthNPaasService) {}
|
|
74
|
-
|
|
75
|
-
@Get(':id')
|
|
76
|
-
async get(@Param('id') id: string) {
|
|
77
|
-
// 不要 try/catch 吞掉——平台故障必须暴露给调用方,别降级成「查无此人」
|
|
78
|
-
const [user] = await this.authn.listUsersByIds([id]);
|
|
79
|
-
if (!user) throw new NotFoundException(`用户 ${id} 不存在`);
|
|
80
|
-
return {
|
|
81
|
-
miaodaUserId: id,
|
|
82
|
-
name: user.name?.zh_cn ?? user.name?.en_us ?? '', // name 是 I18nText,不是字符串
|
|
83
|
-
avatar: user.avatar?.image?.large ?? '', // 头像 URL 在 image.large 上
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
**不要做这两件多余的事**(`PlatformModule.forRoot()` 已经注册了 global 的 `AuthNPaasModule`):
|
|
90
|
-
|
|
91
|
-
- ❌ 在业务 module 里写 `providers: [AuthNPaasService]` —— 会另建一个实例
|
|
92
|
-
- ❌ 往 `package.json` 加 `@lark-apaas/nestjs-authnpaas` 直接依赖 —— 走 `fullstack-nestjs-core` 即可
|
|
93
|
-
|
|
94
|
-
### 禁止(以下任一出现即为错误实现)
|
|
95
|
-
|
|
96
|
-
| ❌ 禁止 | 为什么 |
|
|
97
|
-
|---|---|
|
|
98
|
-
| 复用应用里已有的用户 / 用户资料 service | 那些多半是给浏览器写的,依赖登录态;`/openapi` 下没有登录用户,会静默返回空 |
|
|
99
|
-
| 自己写 HTTP 请求裸调平台通讯录接口 | 鉴权 / 重试 / 日志 / trace 都在 SDK 里,裸调必然漏 |
|
|
100
|
-
| 用 `req.userContext.userId` 做用户查询 | `/openapi` 下**恒为空** |
|
|
101
|
-
| 用 `AuthNPaasService.getCurrentUserLarkUserId()` | 它读 `userId`,取不到只打日志返回 `null`、**不报错**,故障静默 |
|
|
102
|
-
| 从数据库表里 join 出人名 / 头像 | 通讯录不是应用数据,会过期且不 follow 权限 |
|
|
103
|
-
| 按关键词搜人 / 搜部门 / 搜群 | 结果取决于「谁在搜」,`/openapi` 下没有调用者视角,**服务端不提供** |
|
|
104
|
-
| `try/catch` 吞掉 `listUsersByIds` 抛的错、返回空值 | 「平台挂了」和「查无此人」是两回事,吞掉就没人知道故障 |
|
|
105
|
-
| 用 `getBatchLarkUserIds` 判断用户是否存在 | 它是 ID 转换,**外部用户本来就没有 employeeId**,会把外部用户误判成不存在 |
|
|
106
|
-
| 服务端拿不到的字段用 `''` 占位塞进响应 | 调用方会以为字段存在只是没值。拿不到就**从响应类型里删掉**,并告诉用户改走飞书开放平台通讯录 API |
|
|
107
|
-
|
|
108
|
-
```typescript
|
|
109
|
-
// ❌ 错误:包了一层应用现有的用户服务
|
|
110
|
-
const profile = await this.userProfileService.findById(id);
|
|
111
|
-
|
|
112
|
-
// ❌ 错误:OpenAPI 态搜不了人
|
|
113
|
-
const users = await someSearchApi({ query: keyword });
|
|
114
|
-
|
|
115
|
-
// ❌ 错误:读 userId,OpenAPI 态恒为空且失败不报错
|
|
116
|
-
const larkId = await this.authn.getCurrentUserLarkUserId();
|
|
117
|
-
|
|
118
|
-
// ❌ 错误:吞掉平台故障 + 拿不到的字段用空串占位
|
|
119
|
-
let name = '';
|
|
120
|
-
try {
|
|
121
|
-
const [u] = await this.authn.listUsersByIds([id]);
|
|
122
|
-
name = u?.name?.zh_cn ?? '';
|
|
123
|
-
} catch { /* 平台挂了也当查无此人 */ }
|
|
124
|
-
return { userId: id, name, department: '', email: '', jobTitle: '' };
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
### `AuthNPaasService` import 不到时
|
|
128
|
-
|
|
129
|
-
说明当前 `@lark-apaas/fullstack-nestjs-core` 版本还没带上这个能力。**报告阻塞、请人升级 core 版本**——不要加 `@lark-apaas/nestjs-authnpaas` 直接依赖绕过去,也不要退回上面任何一种禁止写法或自己实现替代品。
|
|
130
|
-
|
|
131
|
-
### 服务端拿不到的字段
|
|
132
|
-
|
|
133
|
-
手机号 / 职位 / 工号 / 直属上级 / 在职状态、以及按 ID 查群 —— 服务端**没有**这些能力。需要就走飞书开放平台通讯录 API,或把该能力放回前端做,**不要**在 `/openapi` 里自己拼一个。
|
|
134
|
-
|
|
135
|
-
完整能力边界与字段口径见 [`contacts-service` › 第六节](../contacts-service/SKILL.md)。
|
|
136
|
-
|
|
137
62
|
## 模块组织
|
|
138
63
|
|
|
139
64
|
`/openapi` Controller 和 `/api` Controller 放在**同一个 module** 下,共享 Service 层。用文件名 `xxx.openapi.controller.ts` 区分。
|
|
@@ -443,7 +443,7 @@ NestJS 自己不读 env。直连 NestJS 端口 → header 缺失 → `req.userCo
|
|
|
443
443
|
|
|
444
444
|
- **框架**: React 19 + TypeScript
|
|
445
445
|
- **路由**: React Router DOM v6
|
|
446
|
-
- **样式**:
|
|
446
|
+
- **样式**: tailwindcss(语义化 token)为主,复杂 CSS 用 CSS Modules(`*.module.css`),动态计算值用行内 `style`
|
|
447
447
|
- **UI 组件库**: shadcn/ui — Use components for functionality, heavily style them
|
|
448
448
|
- **图表**: ReactECharts,**开发前必须调用 `/charts-skill`**
|
|
449
449
|
- **图标**: Lucide React(唯一图标库,禁止 Emoji 和其他图标库)
|
|
@@ -535,7 +535,7 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
535
535
|
```
|
|
536
536
|
需要写样式?
|
|
537
537
|
├─ 基础布局/间距/颜色 → Tailwind ✅
|
|
538
|
-
├─ 复杂动画/伪元素/高级CSS →
|
|
538
|
+
├─ 复杂动画/伪元素/高级CSS → CSS Modules(`*.module.css`)✅
|
|
539
539
|
└─ JS动态计算值 → 行内 style ✅
|
|
540
540
|
```
|
|
541
541
|
|
|
@@ -548,10 +548,12 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
548
548
|
- **arbitrary values 中空格用下划线**:`from-[hsl(215_60%_18%)]` 非 `from-[hsl(215 60% 18%)]`
|
|
549
549
|
- `tailwind-theme.css` 自定义属性用 `hsl(H, S%, L%)` 格式(非 `23 10% 23%`)
|
|
550
550
|
|
|
551
|
-
###
|
|
551
|
+
### CSS Modules 规范
|
|
552
552
|
|
|
553
|
-
-
|
|
554
|
-
-
|
|
553
|
+
- 文件名:`ComponentName.module.css`,与组件同目录
|
|
554
|
+
- 导入用 `import styles from './ComponentName.module.css'`,通过 `className={styles.foo}` 引用
|
|
555
|
+
- 类名用 camelCase,避免使用连字符(`styles.myClass` 而非 `styles['my-class']`)
|
|
556
|
+
- 全局样式(如动画 keyframes、CSS 变量)放在 `client/src/index.css` 或 `tailwind-theme.css`
|
|
555
557
|
|
|
556
558
|
### 布局/排版
|
|
557
559
|
|