Просмотр исходного кода

chore(init): initialize mjava-ai with baseline spec and openspec workflow

- import mjava 基座 + 3 个客户子项目 (mcli/shunfeng/guangming) 源码
- 建立 OpenSpec 工作流目录 (changes / specs / opsx 命令与技能)
- 新增 openspec/specs/project-baseline.md 与 openspec/changes/init-project-baseline/ 四件套
- 新增项目级 CLAUDE.md,链到规范中心与 opsx 工作流
- add-observability-foundation / extract-dingtalk-standard-api 两个既有提案顶部补状态标注
- .gitignore 扩展:application-{dev,test,prod}.yml / driver.yml / frpc.ini / *.key / *.pem / .DS_Store 一律不入库
- 提供 application-prod.yml.example 作为部署模板(真实密钥改走环境变量注入)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk недель назад: 2
Сommit
05cd43c158
100 измененных файлов с 7261 добавлено и 0 удалено
  1. 152 0
      .claude/commands/opsx/apply.md
  2. 157 0
      .claude/commands/opsx/archive.md
  3. 173 0
      .claude/commands/opsx/explore.md
  4. 106 0
      .claude/commands/opsx/propose.md
  5. 156 0
      .claude/skills/openspec-apply-change/SKILL.md
  6. 114 0
      .claude/skills/openspec-archive-change/SKILL.md
  7. 288 0
      .claude/skills/openspec-explore/SKILL.md
  8. 110 0
      .claude/skills/openspec-propose/SKILL.md
  9. 64 0
      .gitignore
  10. 42 0
      CLAUDE.md
  11. 51 0
      mjava-guangming/pom.xml
  12. 23 0
      mjava-guangming/src/main/java/com/malk/guangming/Boot.java
  13. 32 0
      mjava-guangming/src/main/java/com/malk/guangming/config/GuangmingConfig.java
  14. 77 0
      mjava-guangming/src/main/java/com/malk/guangming/controller/MailSsoController.java
  15. 62 0
      mjava-guangming/src/main/java/com/malk/guangming/service/DingTalkAuthService.java
  16. 27 0
      mjava-guangming/src/main/java/com/malk/guangming/util/RSACrypt.java
  17. 33 0
      mjava-guangming/src/main/resources/application-prod.yml.example
  18. 138 0
      mjava-guangming/src/main/resources/static/sso.html
  19. 113 0
      mjava-mcli/pom.xml
  20. 32 0
      mjava-mcli/src/main/java/com/malk/mcli/Boot.java
  21. 37 0
      mjava-mcli/src/main/resources/application-prod.yml.example
  22. 51 0
      mjava-shunfeng/pom.xml
  23. 30 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/Boot.java
  24. 93 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/controller/DdCallbackController.java
  25. 95 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/controller/MeetingController.java
  26. 76 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/server/txmeeting/TxMeetingConf.java
  27. 38 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/server/txmeeting/TxMeetingR.java
  28. 28 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/server/zoom/ZoomConf.java
  29. 45 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/server/zoom/ZoomR.java
  30. 49 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/service/MeetingService.java
  31. 44 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/service/TxMeetingClient.java
  32. 40 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/service/ZoomClient.java
  33. 176 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/service/impl/MeetingServiceImpl.java
  34. 109 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/service/impl/TxMeetingClientImpl.java
  35. 137 0
      mjava-shunfeng/src/main/java/com/malk/shunfeng/service/impl/ZoomClientImpl.java
  36. 48 0
      mjava-shunfeng/src/main/resources/application-prod.yml.example
  37. 51 0
      mjava/pom.xml
  38. 29 0
      mjava/src/main/java/com/malk/Boot.java
  39. 29 0
      mjava/src/main/java/com/malk/base/BaseDao.java
  40. 111 0
      mjava/src/main/java/com/malk/base/BaseDto.java
  41. 64 0
      mjava/src/main/java/com/malk/base/BasePo.java
  42. 9 0
      mjava/src/main/java/com/malk/base/BaseRepository.java
  43. 78 0
      mjava/src/main/java/com/malk/base/JpaMap.java
  44. 35 0
      mjava/src/main/java/com/malk/config/JpaConfiguration.java
  45. 29 0
      mjava/src/main/java/com/malk/config/OpenApiConfig.java
  46. 58 0
      mjava/src/main/java/com/malk/config/WebConfiguration.java
  47. 41 0
      mjava/src/main/java/com/malk/config/mutilSource/DataSourceConfig.java
  48. 80 0
      mjava/src/main/java/com/malk/config/mutilSource/PrimaryConfig.java
  49. 76 0
      mjava/src/main/java/com/malk/config/mutilSource/SlaveConfig.java
  50. 80 0
      mjava/src/main/java/com/malk/controller/DDCallbackController.java
  51. 61 0
      mjava/src/main/java/com/malk/controller/TBCallBackController.java
  52. 103 0
      mjava/src/main/java/com/malk/core/AsyncConfig.java
  53. 29 0
      mjava/src/main/java/com/malk/core/MdcTaskDecorator.java
  54. 42 0
      mjava/src/main/java/com/malk/delegate/DDEvent.java
  55. 17 0
      mjava/src/main/java/com/malk/delegate/McDelegate.java
  56. 25 0
      mjava/src/main/java/com/malk/delegate/TBEvent.java
  57. 57 0
      mjava/src/main/java/com/malk/delegate/impl/DDImplEvent.java
  58. 21 0
      mjava/src/main/java/com/malk/delegate/impl/McImplDelegate.java
  59. 29 0
      mjava/src/main/java/com/malk/delegate/impl/TBImplEvent.java
  60. 215 0
      mjava/src/main/java/com/malk/filter/CatchException.java
  61. 53 0
      mjava/src/main/java/com/malk/filter/ExceptionNotice.java
  62. 32 0
      mjava/src/main/java/com/malk/filter/RequestFilter.java
  63. 41 0
      mjava/src/main/java/com/malk/filter/RequestInterceptor.java
  64. 47 0
      mjava/src/main/java/com/malk/filter/TraceIdFilter.java
  65. 13 0
      mjava/src/main/java/com/malk/repository/dao/primary/McAuthorizationDao.java
  66. 10 0
      mjava/src/main/java/com/malk/repository/dao/primary/McTableDao.java
  67. 11 0
      mjava/src/main/java/com/malk/repository/dao/slave/McTableDao.java
  68. 51 0
      mjava/src/main/java/com/malk/repository/entity/primary/McAuthorizationPo.java
  69. 34 0
      mjava/src/main/java/com/malk/repository/entity/primary/McTablePo.java
  70. 34 0
      mjava/src/main/java/com/malk/repository/entity/slave/McTablePo.java
  71. 61 0
      mjava/src/main/java/com/malk/schedule/McScheduleTask.java
  72. 203 0
      mjava/src/main/java/com/malk/server/aliwork/YDConf.java
  73. 240 0
      mjava/src/main/java/com/malk/server/aliwork/YDParam.java
  74. 32 0
      mjava/src/main/java/com/malk/server/aliwork/YDR.java
  75. 33 0
      mjava/src/main/java/com/malk/server/aliyun/ALYR.java
  76. 42 0
      mjava/src/main/java/com/malk/server/beisen/BSConf.java
  77. 42 0
      mjava/src/main/java/com/malk/server/common/FilePath.java
  78. 87 0
      mjava/src/main/java/com/malk/server/common/McConf.java
  79. 168 0
      mjava/src/main/java/com/malk/server/common/McException.java
  80. 40 0
      mjava/src/main/java/com/malk/server/common/McPage.java
  81. 105 0
      mjava/src/main/java/com/malk/server/common/McR.java
  82. 33 0
      mjava/src/main/java/com/malk/server/common/McREnum.java
  83. 56 0
      mjava/src/main/java/com/malk/server/common/VenR.java
  84. 86 0
      mjava/src/main/java/com/malk/server/dingtalk/DDConf.java
  85. 78 0
      mjava/src/main/java/com/malk/server/dingtalk/DDConfigSign.java
  86. 79 0
      mjava/src/main/java/com/malk/server/dingtalk/DDFormComponentDto.java
  87. 24 0
      mjava/src/main/java/com/malk/server/dingtalk/DDInterActiveCard.java
  88. 94 0
      mjava/src/main/java/com/malk/server/dingtalk/DDR.java
  89. 175 0
      mjava/src/main/java/com/malk/server/dingtalk/DDR_New.java
  90. 404 0
      mjava/src/main/java/com/malk/server/dingtalk/crypto/DingCallbackCrypto.java
  91. 42 0
      mjava/src/main/java/com/malk/server/ekuaibao/EKBConf.java
  92. 56 0
      mjava/src/main/java/com/malk/server/ekuaibao/EKBR.java
  93. 24 0
      mjava/src/main/java/com/malk/server/fxiaoke/FXKConf.java
  94. 67 0
      mjava/src/main/java/com/malk/server/fxiaoke/FXXR.java
  95. 59 0
      mjava/src/main/java/com/malk/server/h3yun/CYConf.java
  96. 92 0
      mjava/src/main/java/com/malk/server/teambition/TBConf.java
  97. 38 0
      mjava/src/main/java/com/malk/server/teambition/TBR.java
  98. 26 0
      mjava/src/main/java/com/malk/server/vika/VKConf.java
  99. 34 0
      mjava/src/main/java/com/malk/server/vika/VKR.java
  100. 0 0
      mjava/src/main/java/com/malk/server/xbongbong/DigestUtil.java

+ 152 - 0
.claude/commands/opsx/apply.md

@@ -0,0 +1,152 @@
+---
+name: "OPSX: Apply"
+description: Implement tasks from an OpenSpec change (Experimental)
+category: Workflow
+tags: [workflow, artifacts, experimental]
+---
+
+Implement tasks from an OpenSpec change.
+
+**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+
+**Steps**
+
+1. **Select the change**
+
+   If a name is provided, use it. Otherwise:
+   - Infer from conversation context if the user mentioned a change
+   - Auto-select if only one active change exists
+   - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
+
+   Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
+
+2. **Check status to understand the schema**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used (e.g., "spec-driven")
+   - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
+
+3. **Get apply instructions**
+
+   ```bash
+   openspec instructions apply --change "<name>" --json
+   ```
+
+   This returns:
+   - Context file paths (varies by schema)
+   - Progress (total, complete, remaining)
+   - Task list with status
+   - Dynamic instruction based on current state
+
+   **Handle states:**
+   - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
+   - If `state: "all_done"`: congratulate, suggest archive
+   - Otherwise: proceed to implementation
+
+4. **Read context files**
+
+   Read the files listed in `contextFiles` from the apply instructions output.
+   The files depend on the schema being used:
+   - **spec-driven**: proposal, specs, design, tasks
+   - Other schemas: follow the contextFiles from CLI output
+
+5. **Show current progress**
+
+   Display:
+   - Schema being used
+   - Progress: "N/M tasks complete"
+   - Remaining tasks overview
+   - Dynamic instruction from CLI
+
+6. **Implement tasks (loop until done or blocked)**
+
+   For each pending task:
+   - Show which task is being worked on
+   - Make the code changes required
+   - Keep changes minimal and focused
+   - Mark task complete in the tasks file: `- [ ]` → `- [x]`
+   - Continue to next task
+
+   **Pause if:**
+   - Task is unclear → ask for clarification
+   - Implementation reveals a design issue → suggest updating artifacts
+   - Error or blocker encountered → report and wait for guidance
+   - User interrupts
+
+7. **On completion or pause, show status**
+
+   Display:
+   - Tasks completed this session
+   - Overall progress: "N/M tasks complete"
+   - If all done: suggest archive
+   - If paused: explain why and wait for guidance
+
+**Output During Implementation**
+
+```
+## Implementing: <change-name> (schema: <schema-name>)
+
+Working on task 3/7: <task description>
+[...implementation happening...]
+✓ Task complete
+
+Working on task 4/7: <task description>
+[...implementation happening...]
+✓ Task complete
+```
+
+**Output On Completion**
+
+```
+## Implementation Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 7/7 tasks complete ✓
+
+### Completed This Session
+- [x] Task 1
+- [x] Task 2
+...
+
+All tasks complete! You can archive this change with `/opsx:archive`.
+```
+
+**Output On Pause (Issue Encountered)**
+
+```
+## Implementation Paused
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 4/7 tasks complete
+
+### Issue Encountered
+<description of the issue>
+
+**Options:**
+1. <option 1>
+2. <option 2>
+3. Other approach
+
+What would you like to do?
+```
+
+**Guardrails**
+- Keep going through tasks until done or blocked
+- Always read context files before starting (from the apply instructions output)
+- If task is ambiguous, pause and ask before implementing
+- If implementation reveals issues, pause and suggest artifact updates
+- Keep code changes minimal and scoped to each task
+- Update task checkbox immediately after completing each task
+- Pause on errors, blockers, or unclear requirements - don't guess
+- Use contextFiles from CLI output, don't assume specific file names
+
+**Fluid Workflow Integration**
+
+This skill supports the "actions on a change" model:
+
+- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
+- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

+ 157 - 0
.claude/commands/opsx/archive.md

@@ -0,0 +1,157 @@
+---
+name: "OPSX: Archive"
+description: Archive a completed change in the experimental workflow
+category: Workflow
+tags: [workflow, archive, experimental]
+---
+
+Archive a completed change in the experimental workflow.
+
+**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+
+**Steps**
+
+1. **If no change name provided, prompt for selection**
+
+   Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
+
+   Show only active changes (not already archived).
+   Include the schema used for each change if available.
+
+   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
+
+2. **Check artifact completion status**
+
+   Run `openspec status --change "<name>" --json` to check artifact completion.
+
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used
+   - `artifacts`: List of artifacts with their status (`done` or other)
+
+   **If any artifacts are not `done`:**
+   - Display warning listing incomplete artifacts
+   - Prompt user for confirmation to continue
+   - Proceed if user confirms
+
+3. **Check task completion status**
+
+   Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
+
+   Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
+
+   **If incomplete tasks found:**
+   - Display warning showing count of incomplete tasks
+   - Prompt user for confirmation to continue
+   - Proceed if user confirms
+
+   **If no tasks file exists:** Proceed without task-related warning.
+
+4. **Assess delta spec sync state**
+
+   Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
+
+   **If delta specs exist:**
+   - Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
+   - Determine what changes would be applied (adds, modifications, removals, renames)
+   - Show a combined summary before prompting
+
+   **Prompt options:**
+   - If changes needed: "Sync now (recommended)", "Archive without syncing"
+   - If already synced: "Archive now", "Sync anyway", "Cancel"
+
+   If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
+
+5. **Perform the archive**
+
+   Create the archive directory if it doesn't exist:
+   ```bash
+   mkdir -p openspec/changes/archive
+   ```
+
+   Generate target name using current date: `YYYY-MM-DD-<change-name>`
+
+   **Check if target already exists:**
+   - If yes: Fail with error, suggest renaming existing archive or using different date
+   - If no: Move the change directory to archive
+
+   ```bash
+   mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
+   ```
+
+6. **Display summary**
+
+   Show archive completion summary including:
+   - Change name
+   - Schema that was used
+   - Archive location
+   - Spec sync status (synced / sync skipped / no delta specs)
+   - Note about any warnings (incomplete artifacts/tasks)
+
+**Output On Success**
+
+```
+## Archive Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** ✓ Synced to main specs
+
+All artifacts complete. All tasks complete.
+```
+
+**Output On Success (No Delta Specs)**
+
+```
+## Archive Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** No delta specs
+
+All artifacts complete. All tasks complete.
+```
+
+**Output On Success With Warnings**
+
+```
+## Archive Complete (with warnings)
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** Sync skipped (user chose to skip)
+
+**Warnings:**
+- Archived with 2 incomplete artifacts
+- Archived with 3 incomplete tasks
+- Delta spec sync was skipped (user chose to skip)
+
+Review the archive if this was not intentional.
+```
+
+**Output On Error (Archive Exists)**
+
+```
+## Archive Failed
+
+**Change:** <change-name>
+**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
+
+Target archive directory already exists.
+
+**Options:**
+1. Rename the existing archive
+2. Delete the existing archive if it's a duplicate
+3. Wait until a different date to archive
+```
+
+**Guardrails**
+- Always prompt for change selection if not provided
+- Use artifact graph (openspec status --json) for completion checking
+- Don't block archive on warnings - just inform and confirm
+- Preserve .openspec.yaml when moving to archive (it moves with the directory)
+- Show clear summary of what happened
+- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
+- If delta specs exist, always run the sync assessment and show the combined summary before prompting

+ 173 - 0
.claude/commands/opsx/explore.md

@@ -0,0 +1,173 @@
+---
+name: "OPSX: Explore"
+description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
+category: Workflow
+tags: [workflow, explore, experimental, thinking]
+---
+
+Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
+
+**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
+
+**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
+
+**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
+- A vague idea: "real-time collaboration"
+- A specific problem: "the auth system is getting unwieldy"
+- A change name: "add-dark-mode" (to explore in context of that change)
+- A comparison: "postgres vs sqlite for this"
+- Nothing (just enter explore mode)
+
+---
+
+## The Stance
+
+- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
+- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
+- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
+- **Adaptive** - Follow interesting threads, pivot when new information emerges
+- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
+- **Grounded** - Explore the actual codebase when relevant, don't just theorize
+
+---
+
+## What You Might Do
+
+Depending on what the user brings, you might:
+
+**Explore the problem space**
+- Ask clarifying questions that emerge from what they said
+- Challenge assumptions
+- Reframe the problem
+- Find analogies
+
+**Investigate the codebase**
+- Map existing architecture relevant to the discussion
+- Find integration points
+- Identify patterns already in use
+- Surface hidden complexity
+
+**Compare options**
+- Brainstorm multiple approaches
+- Build comparison tables
+- Sketch tradeoffs
+- Recommend a path (if asked)
+
+**Visualize**
+```
+┌─────────────────────────────────────────┐
+│     Use ASCII diagrams liberally        │
+├─────────────────────────────────────────┤
+│                                         │
+│      ┌────────┐         ┌────────┐      │
+│      │ State  │────────▶│ State  │      │
+│      │   A    │         │   B    │      │
+│      └────────┘         └────────┘      │
+│                                         │
+│   System diagrams, state machines,      │
+│   data flows, architecture sketches,    │
+│   dependency graphs, comparison tables  │
+│                                         │
+└─────────────────────────────────────────┘
+```
+
+**Surface risks and unknowns**
+- Identify what could go wrong
+- Find gaps in understanding
+- Suggest spikes or investigations
+
+---
+
+## OpenSpec Awareness
+
+You have full context of the OpenSpec system. Use it naturally, don't force it.
+
+### Check for context
+
+At the start, quickly check what exists:
+```bash
+openspec list --json
+```
+
+This tells you:
+- If there are active changes
+- Their names, schemas, and status
+- What the user might be working on
+
+If the user mentioned a specific change name, read its artifacts for context.
+
+### When no change exists
+
+Think freely. When insights crystallize, you might offer:
+
+- "This feels solid enough to start a change. Want me to create a proposal?"
+- Or keep exploring - no pressure to formalize
+
+### When a change exists
+
+If the user mentions a change or you detect one is relevant:
+
+1. **Read existing artifacts for context**
+   - `openspec/changes/<name>/proposal.md`
+   - `openspec/changes/<name>/design.md`
+   - `openspec/changes/<name>/tasks.md`
+   - etc.
+
+2. **Reference them naturally in conversation**
+   - "Your design mentions using Redis, but we just realized SQLite fits better..."
+   - "The proposal scopes this to premium users, but we're now thinking everyone..."
+
+3. **Offer to capture when decisions are made**
+
+    | Insight Type               | Where to Capture               |
+    |----------------------------|--------------------------------|
+    | New requirement discovered | `specs/<capability>/spec.md` |
+    | Requirement changed        | `specs/<capability>/spec.md` |
+    | Design decision made       | `design.md`                  |
+    | Scope changed              | `proposal.md`                |
+    | New work identified        | `tasks.md`                   |
+    | Assumption invalidated     | Relevant artifact              |
+
+   Example offers:
+   - "That's a design decision. Capture it in design.md?"
+   - "This is a new requirement. Add it to specs?"
+   - "This changes scope. Update the proposal?"
+
+4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
+
+---
+
+## What You Don't Have To Do
+
+- Follow a script
+- Ask the same questions every time
+- Produce a specific artifact
+- Reach a conclusion
+- Stay on topic if a tangent is valuable
+- Be brief (this is thinking time)
+
+---
+
+## Ending Discovery
+
+There's no required ending. Discovery might:
+
+- **Flow into a proposal**: "Ready to start? I can create a change proposal."
+- **Result in artifact updates**: "Updated design.md with these decisions"
+- **Just provide clarity**: User has what they need, moves on
+- **Continue later**: "We can pick this up anytime"
+
+When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
+
+---
+
+## Guardrails
+
+- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
+- **Don't fake understanding** - If something is unclear, dig deeper
+- **Don't rush** - Discovery is thinking time, not task time
+- **Don't force structure** - Let patterns emerge naturally
+- **Don't auto-capture** - Offer to save insights, don't just do it
+- **Do visualize** - A good diagram is worth many paragraphs
+- **Do explore the codebase** - Ground discussions in reality
+- **Do question assumptions** - Including the user's and your own

+ 106 - 0
.claude/commands/opsx/propose.md

@@ -0,0 +1,106 @@
+---
+name: "OPSX: Propose"
+description: Propose a new change - create it and generate all artifacts in one step
+category: Workflow
+tags: [workflow, artifacts, experimental]
+---
+
+Propose a new change - create the change and generate all artifacts in one step.
+
+I'll create a change with artifacts:
+- proposal.md (what & why)
+- design.md (how)
+- tasks.md (implementation steps)
+
+When ready to implement, run /opsx:apply
+
+---
+
+**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
+
+**Steps**
+
+1. **If no input provided, ask what they want to build**
+
+   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
+   > "What change do you want to work on? Describe what you want to build or fix."
+
+   From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
+
+   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
+
+2. **Create the change directory**
+   ```bash
+   openspec new change "<name>"
+   ```
+   This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
+
+3. **Get the artifact build order**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to get:
+   - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
+   - `artifacts`: list of all artifacts with their status and dependencies
+
+4. **Create artifacts in sequence until apply-ready**
+
+   Use the **TodoWrite tool** to track progress through the artifacts.
+
+   Loop through artifacts in dependency order (artifacts with no pending dependencies first):
+
+   a. **For each artifact that is `ready` (dependencies satisfied)**:
+      - Get instructions:
+        ```bash
+        openspec instructions <artifact-id> --change "<name>" --json
+        ```
+      - The instructions JSON includes:
+        - `context`: Project background (constraints for you - do NOT include in output)
+        - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
+        - `template`: The structure to use for your output file
+        - `instruction`: Schema-specific guidance for this artifact type
+        - `outputPath`: Where to write the artifact
+        - `dependencies`: Completed artifacts to read for context
+      - Read any completed dependency files for context
+      - Create the artifact file using `template` as the structure
+      - Apply `context` and `rules` as constraints - but do NOT copy them into the file
+      - Show brief progress: "Created <artifact-id>"
+
+   b. **Continue until all `applyRequires` artifacts are complete**
+      - After creating each artifact, re-run `openspec status --change "<name>" --json`
+      - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
+      - Stop when all `applyRequires` artifacts are done
+
+   c. **If an artifact requires user input** (unclear context):
+      - Use **AskUserQuestion tool** to clarify
+      - Then continue with creation
+
+5. **Show final status**
+   ```bash
+   openspec status --change "<name>"
+   ```
+
+**Output**
+
+After completing all artifacts, summarize:
+- Change name and location
+- List of artifacts created with brief descriptions
+- What's ready: "All artifacts created! Ready for implementation."
+- Prompt: "Run `/opsx:apply` to start implementing."
+
+**Artifact Creation Guidelines**
+
+- Follow the `instruction` field from `openspec instructions` for each artifact type
+- The schema defines what each artifact should contain - follow it
+- Read dependency artifacts for context before creating new ones
+- Use `template` as the structure for your output file - fill in its sections
+- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
+  - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
+  - These guide what you write, but should never appear in the output
+
+**Guardrails**
+- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
+- Always read dependency artifacts before creating a new one
+- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
+- If a change with that name already exists, ask if user wants to continue it or create a new one
+- Verify each artifact file exists after writing before proceeding to next

+ 156 - 0
.claude/skills/openspec-apply-change/SKILL.md

@@ -0,0 +1,156 @@
+---
+name: openspec-apply-change
+description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
+license: MIT
+compatibility: Requires openspec CLI.
+metadata:
+  author: openspec
+  version: "1.0"
+  generatedBy: "1.3.0"
+---
+
+Implement tasks from an OpenSpec change.
+
+**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+
+**Steps**
+
+1. **Select the change**
+
+   If a name is provided, use it. Otherwise:
+   - Infer from conversation context if the user mentioned a change
+   - Auto-select if only one active change exists
+   - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
+
+   Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
+
+2. **Check status to understand the schema**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used (e.g., "spec-driven")
+   - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
+
+3. **Get apply instructions**
+
+   ```bash
+   openspec instructions apply --change "<name>" --json
+   ```
+
+   This returns:
+   - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
+   - Progress (total, complete, remaining)
+   - Task list with status
+   - Dynamic instruction based on current state
+
+   **Handle states:**
+   - If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
+   - If `state: "all_done"`: congratulate, suggest archive
+   - Otherwise: proceed to implementation
+
+4. **Read context files**
+
+   Read the files listed in `contextFiles` from the apply instructions output.
+   The files depend on the schema being used:
+   - **spec-driven**: proposal, specs, design, tasks
+   - Other schemas: follow the contextFiles from CLI output
+
+5. **Show current progress**
+
+   Display:
+   - Schema being used
+   - Progress: "N/M tasks complete"
+   - Remaining tasks overview
+   - Dynamic instruction from CLI
+
+6. **Implement tasks (loop until done or blocked)**
+
+   For each pending task:
+   - Show which task is being worked on
+   - Make the code changes required
+   - Keep changes minimal and focused
+   - Mark task complete in the tasks file: `- [ ]` → `- [x]`
+   - Continue to next task
+
+   **Pause if:**
+   - Task is unclear → ask for clarification
+   - Implementation reveals a design issue → suggest updating artifacts
+   - Error or blocker encountered → report and wait for guidance
+   - User interrupts
+
+7. **On completion or pause, show status**
+
+   Display:
+   - Tasks completed this session
+   - Overall progress: "N/M tasks complete"
+   - If all done: suggest archive
+   - If paused: explain why and wait for guidance
+
+**Output During Implementation**
+
+```
+## Implementing: <change-name> (schema: <schema-name>)
+
+Working on task 3/7: <task description>
+[...implementation happening...]
+✓ Task complete
+
+Working on task 4/7: <task description>
+[...implementation happening...]
+✓ Task complete
+```
+
+**Output On Completion**
+
+```
+## Implementation Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 7/7 tasks complete ✓
+
+### Completed This Session
+- [x] Task 1
+- [x] Task 2
+...
+
+All tasks complete! Ready to archive this change.
+```
+
+**Output On Pause (Issue Encountered)**
+
+```
+## Implementation Paused
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 4/7 tasks complete
+
+### Issue Encountered
+<description of the issue>
+
+**Options:**
+1. <option 1>
+2. <option 2>
+3. Other approach
+
+What would you like to do?
+```
+
+**Guardrails**
+- Keep going through tasks until done or blocked
+- Always read context files before starting (from the apply instructions output)
+- If task is ambiguous, pause and ask before implementing
+- If implementation reveals issues, pause and suggest artifact updates
+- Keep code changes minimal and scoped to each task
+- Update task checkbox immediately after completing each task
+- Pause on errors, blockers, or unclear requirements - don't guess
+- Use contextFiles from CLI output, don't assume specific file names
+
+**Fluid Workflow Integration**
+
+This skill supports the "actions on a change" model:
+
+- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
+- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

+ 114 - 0
.claude/skills/openspec-archive-change/SKILL.md

@@ -0,0 +1,114 @@
+---
+name: openspec-archive-change
+description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
+license: MIT
+compatibility: Requires openspec CLI.
+metadata:
+  author: openspec
+  version: "1.0"
+  generatedBy: "1.3.0"
+---
+
+Archive a completed change in the experimental workflow.
+
+**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+
+**Steps**
+
+1. **If no change name provided, prompt for selection**
+
+   Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
+
+   Show only active changes (not already archived).
+   Include the schema used for each change if available.
+
+   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
+
+2. **Check artifact completion status**
+
+   Run `openspec status --change "<name>" --json` to check artifact completion.
+
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used
+   - `artifacts`: List of artifacts with their status (`done` or other)
+
+   **If any artifacts are not `done`:**
+   - Display warning listing incomplete artifacts
+   - Use **AskUserQuestion tool** to confirm user wants to proceed
+   - Proceed if user confirms
+
+3. **Check task completion status**
+
+   Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
+
+   Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
+
+   **If incomplete tasks found:**
+   - Display warning showing count of incomplete tasks
+   - Use **AskUserQuestion tool** to confirm user wants to proceed
+   - Proceed if user confirms
+
+   **If no tasks file exists:** Proceed without task-related warning.
+
+4. **Assess delta spec sync state**
+
+   Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
+
+   **If delta specs exist:**
+   - Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
+   - Determine what changes would be applied (adds, modifications, removals, renames)
+   - Show a combined summary before prompting
+
+   **Prompt options:**
+   - If changes needed: "Sync now (recommended)", "Archive without syncing"
+   - If already synced: "Archive now", "Sync anyway", "Cancel"
+
+   If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
+
+5. **Perform the archive**
+
+   Create the archive directory if it doesn't exist:
+   ```bash
+   mkdir -p openspec/changes/archive
+   ```
+
+   Generate target name using current date: `YYYY-MM-DD-<change-name>`
+
+   **Check if target already exists:**
+   - If yes: Fail with error, suggest renaming existing archive or using different date
+   - If no: Move the change directory to archive
+
+   ```bash
+   mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
+   ```
+
+6. **Display summary**
+
+   Show archive completion summary including:
+   - Change name
+   - Schema that was used
+   - Archive location
+   - Whether specs were synced (if applicable)
+   - Note about any warnings (incomplete artifacts/tasks)
+
+**Output On Success**
+
+```
+## Archive Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
+
+All artifacts complete. All tasks complete.
+```
+
+**Guardrails**
+- Always prompt for change selection if not provided
+- Use artifact graph (openspec status --json) for completion checking
+- Don't block archive on warnings - just inform and confirm
+- Preserve .openspec.yaml when moving to archive (it moves with the directory)
+- Show clear summary of what happened
+- If sync is requested, use openspec-sync-specs approach (agent-driven)
+- If delta specs exist, always run the sync assessment and show the combined summary before prompting

+ 288 - 0
.claude/skills/openspec-explore/SKILL.md

@@ -0,0 +1,288 @@
+---
+name: openspec-explore
+description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
+license: MIT
+compatibility: Requires openspec CLI.
+metadata:
+  author: openspec
+  version: "1.0"
+  generatedBy: "1.3.0"
+---
+
+Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
+
+**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
+
+**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
+
+---
+
+## The Stance
+
+- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
+- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
+- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
+- **Adaptive** - Follow interesting threads, pivot when new information emerges
+- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
+- **Grounded** - Explore the actual codebase when relevant, don't just theorize
+
+---
+
+## What You Might Do
+
+Depending on what the user brings, you might:
+
+**Explore the problem space**
+- Ask clarifying questions that emerge from what they said
+- Challenge assumptions
+- Reframe the problem
+- Find analogies
+
+**Investigate the codebase**
+- Map existing architecture relevant to the discussion
+- Find integration points
+- Identify patterns already in use
+- Surface hidden complexity
+
+**Compare options**
+- Brainstorm multiple approaches
+- Build comparison tables
+- Sketch tradeoffs
+- Recommend a path (if asked)
+
+**Visualize**
+```
+┌─────────────────────────────────────────┐
+│     Use ASCII diagrams liberally        │
+├─────────────────────────────────────────┤
+│                                         │
+│      ┌────────┐         ┌────────┐      │
+│      │ State  │────────▶│ State  │      │
+│      │   A    │         │   B    │      │
+│      └────────┘         └────────┘      │
+│                                         │
+│   System diagrams, state machines,      │
+│   data flows, architecture sketches,    │
+│   dependency graphs, comparison tables  │
+│                                         │
+└─────────────────────────────────────────┘
+```
+
+**Surface risks and unknowns**
+- Identify what could go wrong
+- Find gaps in understanding
+- Suggest spikes or investigations
+
+---
+
+## OpenSpec Awareness
+
+You have full context of the OpenSpec system. Use it naturally, don't force it.
+
+### Check for context
+
+At the start, quickly check what exists:
+```bash
+openspec list --json
+```
+
+This tells you:
+- If there are active changes
+- Their names, schemas, and status
+- What the user might be working on
+
+### When no change exists
+
+Think freely. When insights crystallize, you might offer:
+
+- "This feels solid enough to start a change. Want me to create a proposal?"
+- Or keep exploring - no pressure to formalize
+
+### When a change exists
+
+If the user mentions a change or you detect one is relevant:
+
+1. **Read existing artifacts for context**
+   - `openspec/changes/<name>/proposal.md`
+   - `openspec/changes/<name>/design.md`
+   - `openspec/changes/<name>/tasks.md`
+   - etc.
+
+2. **Reference them naturally in conversation**
+   - "Your design mentions using Redis, but we just realized SQLite fits better..."
+   - "The proposal scopes this to premium users, but we're now thinking everyone..."
+
+3. **Offer to capture when decisions are made**
+
+    | Insight Type               | Where to Capture               |
+    |----------------------------|--------------------------------|
+    | New requirement discovered | `specs/<capability>/spec.md` |
+    | Requirement changed        | `specs/<capability>/spec.md` |
+    | Design decision made       | `design.md`                  |
+    | Scope changed              | `proposal.md`                |
+    | New work identified        | `tasks.md`                   |
+    | Assumption invalidated     | Relevant artifact              |
+
+   Example offers:
+   - "That's a design decision. Capture it in design.md?"
+   - "This is a new requirement. Add it to specs?"
+   - "This changes scope. Update the proposal?"
+
+4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
+
+---
+
+## What You Don't Have To Do
+
+- Follow a script
+- Ask the same questions every time
+- Produce a specific artifact
+- Reach a conclusion
+- Stay on topic if a tangent is valuable
+- Be brief (this is thinking time)
+
+---
+
+## Handling Different Entry Points
+
+**User brings a vague idea:**
+```
+User: I'm thinking about adding real-time collaboration
+
+You: Real-time collab is a big space. Let me think about this...
+
+      COLLABORATION SPECTRUM
+      ════════════════════════════════════════════
+
+      Awareness          Coordination         Sync
+          │                   │                 │
+          ▼                   ▼                 ▼
+      ┌────────┐         ┌────────┐        ┌────────┐
+      │Presence│         │Cursors │        │  CRDT  │
+      │  "3    │         │ Multi  │        │Conflict│
+      │online" │         │ select │        │  free  │
+      └────────┘         └────────┘        └────────┘
+          │                   │                 │
+       trivial            moderate           complex
+
+      Where's your head at?
+```
+
+**User brings a specific problem:**
+```
+User: The auth system is a mess
+
+You: [reads codebase]
+
+     ┌─────────────────────────────────────────────┐
+     │              CURRENT AUTH FLOW              │
+     └─────────────────────────────────────────────┘
+                        │
+          ┌─────────────┼─────────────┐
+          ▼             ▼             ▼
+     ┌─────────┐  ┌─────────┐  ┌─────────┐
+     │ Google  │  │  GitHub │  │  Email  │
+     │  OAuth  │  │  OAuth  │  │  Magic  │
+     └────┬────┘  └────┬────┘  └────┬────┘
+          │             │             │
+          └─────────────┼─────────────┘
+                        ▼
+                  ┌───────────┐
+                  │  Session  │
+                  └─────┬─────┘
+                        │
+                        ▼
+                  ┌───────────┐
+                  │   Perms   │
+                  └───────────┘
+
+     I see three tangles. Which one's burning?
+```
+
+**User is stuck mid-implementation:**
+```
+User: /opsx:explore add-auth-system
+      The OAuth integration is more complex than expected
+
+You: [reads change artifacts]
+
+     You're on task 4: "Implement OAuth flow"
+
+     Let me trace what's involved...
+
+     [draws diagram, explores options, suggests paths]
+
+     Want to update the design to reflect this?
+     Or add a spike task to investigate?
+```
+
+**User wants to compare options:**
+```
+User: Should we use Postgres or SQLite?
+
+You: Generic answer is boring. What's the context?
+
+User: A CLI tool that tracks local dev environments
+
+You: That changes everything.
+
+     ┌─────────────────────────────────────────────────┐
+     │          CLI TOOL DATA STORAGE                  │
+     └─────────────────────────────────────────────────┘
+
+     Key constraints:
+     • No daemon running
+     • Must work offline
+     • Single user
+
+                  SQLite          Postgres
+     Deployment   embedded ✓      needs server ✗
+     Offline      yes ✓           no ✗
+     Single file  yes ✓           no ✗
+
+     SQLite. Not even close.
+
+     Unless... is there a sync component?
+```
+
+---
+
+## Ending Discovery
+
+There's no required ending. Discovery might:
+
+- **Flow into a proposal**: "Ready to start? I can create a change proposal."
+- **Result in artifact updates**: "Updated design.md with these decisions"
+- **Just provide clarity**: User has what they need, moves on
+- **Continue later**: "We can pick this up anytime"
+
+When it feels like things are crystallizing, you might summarize:
+
+```
+## What We Figured Out
+
+**The problem**: [crystallized understanding]
+
+**The approach**: [if one emerged]
+
+**Open questions**: [if any remain]
+
+**Next steps** (if ready):
+- Create a change proposal
+- Keep exploring: just keep talking
+```
+
+But this summary is optional. Sometimes the thinking IS the value.
+
+---
+
+## Guardrails
+
+- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
+- **Don't fake understanding** - If something is unclear, dig deeper
+- **Don't rush** - Discovery is thinking time, not task time
+- **Don't force structure** - Let patterns emerge naturally
+- **Don't auto-capture** - Offer to save insights, don't just do it
+- **Do visualize** - A good diagram is worth many paragraphs
+- **Do explore the codebase** - Ground discussions in reality
+- **Do question assumptions** - Including the user's and your own

+ 110 - 0
.claude/skills/openspec-propose/SKILL.md

@@ -0,0 +1,110 @@
+---
+name: openspec-propose
+description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
+license: MIT
+compatibility: Requires openspec CLI.
+metadata:
+  author: openspec
+  version: "1.0"
+  generatedBy: "1.3.0"
+---
+
+Propose a new change - create the change and generate all artifacts in one step.
+
+I'll create a change with artifacts:
+- proposal.md (what & why)
+- design.md (how)
+- tasks.md (implementation steps)
+
+When ready to implement, run /opsx:apply
+
+---
+
+**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
+
+**Steps**
+
+1. **If no clear input provided, ask what they want to build**
+
+   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
+   > "What change do you want to work on? Describe what you want to build or fix."
+
+   From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
+
+   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
+
+2. **Create the change directory**
+   ```bash
+   openspec new change "<name>"
+   ```
+   This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
+
+3. **Get the artifact build order**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to get:
+   - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
+   - `artifacts`: list of all artifacts with their status and dependencies
+
+4. **Create artifacts in sequence until apply-ready**
+
+   Use the **TodoWrite tool** to track progress through the artifacts.
+
+   Loop through artifacts in dependency order (artifacts with no pending dependencies first):
+
+   a. **For each artifact that is `ready` (dependencies satisfied)**:
+      - Get instructions:
+        ```bash
+        openspec instructions <artifact-id> --change "<name>" --json
+        ```
+      - The instructions JSON includes:
+        - `context`: Project background (constraints for you - do NOT include in output)
+        - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
+        - `template`: The structure to use for your output file
+        - `instruction`: Schema-specific guidance for this artifact type
+        - `outputPath`: Where to write the artifact
+        - `dependencies`: Completed artifacts to read for context
+      - Read any completed dependency files for context
+      - Create the artifact file using `template` as the structure
+      - Apply `context` and `rules` as constraints - but do NOT copy them into the file
+      - Show brief progress: "Created <artifact-id>"
+
+   b. **Continue until all `applyRequires` artifacts are complete**
+      - After creating each artifact, re-run `openspec status --change "<name>" --json`
+      - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
+      - Stop when all `applyRequires` artifacts are done
+
+   c. **If an artifact requires user input** (unclear context):
+      - Use **AskUserQuestion tool** to clarify
+      - Then continue with creation
+
+5. **Show final status**
+   ```bash
+   openspec status --change "<name>"
+   ```
+
+**Output**
+
+After completing all artifacts, summarize:
+- Change name and location
+- List of artifacts created with brief descriptions
+- What's ready: "All artifacts created! Ready for implementation."
+- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
+
+**Artifact Creation Guidelines**
+
+- Follow the `instruction` field from `openspec instructions` for each artifact type
+- The schema defines what each artifact should contain - follow it
+- Read dependency artifacts for context before creating new ones
+- Use `template` as the structure for your output file - fill in its sections
+- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
+  - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
+  - These guide what you write, but should never appear in the output
+
+**Guardrails**
+- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
+- Always read dependency artifacts before creating a new one
+- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
+- If a change with that name already exists, ask if user wants to continue it or create a new one
+- Verify each artifact file exists after writing before proceeding to next

+ 64 - 0
.gitignore

@@ -0,0 +1,64 @@
+# IntelliJ project files
+.idea
+*.iml
+out
+gen
+### Java template
+# Compiled class file
+*.class
+
+# Log file
+*.log
+/log/
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+
+mvnw
+mvnw.cmd
+
+# tmp file
+.tmp
+*/tmp
+*/target
+
+# macOS
+.DS_Store
+*/.DS_Store
+
+# 敏感环境配置(dev/test/prod 只入 .example 模板,真实值仅存本地)
+**/application-dev.yml
+**/application-test.yml
+**/application-prod.yml
+# 多数据源 / 驱动测试配置(含数据库凭据)
+**/driver.yml
+
+# FRP 代理配置(含 token / 公网地址 / dashboard 密码)
+**/frpc.ini
+**/frps.ini
+
+# 私钥与证书
+*.key
+*.pem
+*.crt
+*.p12
+*.jks
+
+# web2 file
+*/src/main/resources/static/web
+*/src/main/resources/static/mjs

+ 42 - 0
CLAUDE.md

@@ -0,0 +1,42 @@
+# mjava-ai
+
+Java 后端基座 + 客户子项目仓库。Spring Boot 2.2.13 + MySQL,第一阶段不引入 Redis/Docker。
+
+## 开发前必读
+
+- 仓库基线:`openspec/specs/project-baseline.md`(代码锚点 + 子项目清单)
+- 通用规范:`/Users/malk/Desktop/Tech/claude/后端/mjava-baseline.md`(11 章,权威)
+- 宜搭特化:`/Users/malk/Desktop/Tech/claude/后端/yida-serverside.md`
+
+## OpenSpec 工作流(opsx)
+
+任何规范变更或功能新增,走 change 流程:
+
+| 命令 | 用途 |
+|------|------|
+| `/opsx:explore` | 在改动前做需求/约束梳理 |
+| `/opsx:propose` | 新建 change(proposal+design+spec+tasks 四件套) |
+| `/opsx:apply` | 执行 tasks.md 的实现步骤 |
+| `/opsx:archive` | 完成后归档到 `openspec/changes/archive/` |
+
+现有 change 状态:
+- `changes/add-observability-foundation/` — 代码已实施,待生产冒烟
+- `changes/extract-dingtalk-standard-api/` — 已完成,待归档
+- `changes/init-project-baseline/` — 文档提案(本次初始化)
+
+## 快速操作
+
+- 新客户接入:复制 `mjava-mcli` 模板,详见基线文档第 9 章
+- 本地编译:`mvn -pl mjava-{module} -am clean compile`
+- 打包:`mvn -pl mjava-{module} -am clean package -Dmaven.test.skip=true`
+
+## 严禁
+
+- ❌ 引入三方 SDK(钉钉/宜搭/飞书 SDK 都禁)—— 统一用 `UtilHttp`
+- ❌ 每请求重新拉 token —— 统一走 `UtilToken` 缓存
+- ❌ 硬编码 appKey/appSecret —— 读 `application-{profile}.yml`
+- ❌ 绕过 JPA 写原生 SQL 字符串拼接
+
+## 当前活跃目录位置
+
+`/Users/malk/server/cur/mjava-ai/`(2026-04-18 从 `/Users/malk/server/mjava-ai` 迁入 cur 层)

+ 51 - 0
mjava-guangming/pom.xml

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>java-mcli</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>mjava-guangming</artifactId>
+    <description>光明集成:钉钉SSO免密登录德惠邮箱</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+        <!-- 核心模块 -->
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>${mjava.version}</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <fork>false</fork>
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 23 - 0
mjava-guangming/src/main/java/com/malk/guangming/Boot.java

@@ -0,0 +1,23 @@
+package com.malk.guangming;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import javax.persistence.EntityManager;
+
+@EnableJpaAuditing
+@SpringBootApplication(scanBasePackages = {"com.malk"})
+public class Boot {
+
+    public static void main(String... args) {
+        SpringApplication.run(Boot.class, args);
+    }
+
+    @Bean
+    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
+        return new JPAQueryFactory(entityManager);
+    }
+}

+ 32 - 0
mjava-guangming/src/main/java/com/malk/guangming/config/GuangmingConfig.java

@@ -0,0 +1,32 @@
+package com.malk.guangming.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "guangming")
+public class GuangmingConfig {
+
+    /** 钉钉配置 */
+    private Dingtalk dingtalk = new Dingtalk();
+
+    /** 邮箱SSO配置 */
+    private MailSso mailSso = new MailSso();
+
+    @Data
+    public static class Dingtalk {
+        private String appKey;
+        private String appSecret;
+        private String corpId;
+    }
+
+    @Data
+    public static class MailSso {
+        /** 德惠邮箱SSO地址 */
+        private String ssoUrl;
+        /** RSA公钥 */
+        private String publicKey;
+    }
+}

+ 77 - 0
mjava-guangming/src/main/java/com/malk/guangming/controller/MailSsoController.java

@@ -0,0 +1,77 @@
+package com.malk.guangming.controller;
+
+import com.malk.guangming.config.GuangmingConfig;
+import com.malk.guangming.service.DingTalkAuthService;
+import com.malk.guangming.util.RSACrypt;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+@Slf4j
+@RestController
+public class MailSsoController {
+
+    @Autowired
+    private DingTalkAuthService dingTalkAuthService;
+
+    @Autowired
+    private GuangmingConfig config;
+
+    /**
+     * 钉钉SSO跳转德惠邮箱
+     * 前端通过JSAPI获取authCode后调用此接口,后端完成认证和加密后302跳转到邮箱
+     *
+     * @param authCode 钉钉免登授权码
+     */
+    @GetMapping("/sso/mail")
+    public void ssoMail(@RequestParam String authCode, HttpServletResponse response) throws IOException {
+        try {
+            // 1. 通过authCode获取用户userId
+            String userId = dingTalkAuthService.getUserId(authCode);
+            log.info("[SSO邮箱] userId: {}", userId);
+
+            // 2. 获取用户邮箱
+//            String email = dingTalkAuthService.getUserEmail(userId);
+//            String email = "leixiaochuan@brightfood.com";
+            String email = "linke@brightfood.com";
+
+            log.info("[SSO邮箱] email: {}", email);
+
+            // 3. 截取邮箱@前面的部分作为uid
+            String uid = email.contains("@") ? email.substring(0, email.indexOf("@")) : email;
+
+            // 4. RSA加密 + URL安全处理
+            String encryptedUid = RSACrypt.encrypt(uid, config.getMailSso().getPublicKey());
+            String safeUid = RSACrypt.toUrlSafe(encryptedUid);
+
+            // 5. 拼接SSO URL并跳转
+            String ssoUrl = config.getMailSso().getSsoUrl() + "&uid=" + safeUid;
+            log.info("[SSO邮箱] 跳转: {}", ssoUrl);
+            response.sendRedirect(ssoUrl);
+
+        } catch (Exception e) {
+            log.error("[SSO邮箱] 登录失败", e);
+            response.setContentType("text/html;charset=utf-8");
+            String errMsg = e.getMessage() != null ? e.getMessage().replace("'", "\\'") : "未知错误";
+            response.getWriter().write(
+                "<!DOCTYPE html><html><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1.0'>"
+                + "<script src='https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js'></script></head>"
+                + "<body><script>"
+                + "if(/DingTalk/i.test(navigator.userAgent)){"
+                +   "dd.device.notification.alert({title:'登录失败',message:'" + errMsg + "',buttonName:'返回',"
+                +     "onSuccess:function(){dd.biz.navigation.close();}});"
+                + "}else{"
+                +   "document.body.innerHTML='<div style=\"display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif\">"
+                +     "<div style=\"text-align:center\"><div style=\"width:48px;height:48px;margin:0 auto 16px;border-radius:50%;background:#fee;display:flex;justify-content:center;align-items:center;font-size:24px\">&#10060;</div>"
+                +     "<p style=\"font-size:15px;color:#666\">" + errMsg + "</p></div></div>';"
+                + "}"
+                + "</script></body></html>"
+            );
+        }
+    }
+}

+ 62 - 0
mjava-guangming/src/main/java/com/malk/guangming/service/DingTalkAuthService.java

@@ -0,0 +1,62 @@
+package com.malk.guangming.service;
+
+import com.malk.guangming.config.GuangmingConfig;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Slf4j
+@Service
+public class DingTalkAuthService {
+
+    @Autowired
+    private GuangmingConfig config;
+
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private DDClient_Contacts ddClientContacts;
+
+    /**
+     * 通过免登授权码获取用户userId
+     *
+     * @param authCode 前端JSAPI获取的免登授权码
+     * @return 用户userId
+     */
+    public String getUserId(String authCode) {
+        String accessToken = ddClient.getAccessToken(
+                config.getDingtalk().getAppKey(),
+                config.getDingtalk().getAppSecret());
+        Map userInfo = ddClient.getUserInfoByCode(accessToken, authCode);
+        return userInfo.get("userid").toString();
+    }
+
+    /**
+     * 根据userId获取用户邮箱
+     *
+     * @param userId 钉钉用户ID
+     * @return 用户企业邮箱
+     */
+    public String getUserEmail(String userId) {
+        String accessToken = ddClient.getAccessToken(
+                config.getDingtalk().getAppKey(),
+                config.getDingtalk().getAppSecret());
+        Map userInfo = ddClientContacts.getUserInfoById(accessToken, userId);
+        // 优先取企业邮箱(org_email),其次取个人邮箱(email)
+        Object orgEmail = userInfo.get("org_email");
+        if (orgEmail != null && !orgEmail.toString().isEmpty()) {
+            return orgEmail.toString();
+        }
+        Object email = userInfo.get("email");
+        if (email != null && !email.toString().isEmpty()) {
+            return email.toString();
+        }
+        log.error("[钉钉] 用户 {} 未配置邮箱, 返回字段: {}", userId, userInfo.keySet());
+        throw new RuntimeException("该用户未配置企业邮箱");
+    }
+}

+ 27 - 0
mjava-guangming/src/main/java/com/malk/guangming/util/RSACrypt.java

@@ -0,0 +1,27 @@
+package com.malk.guangming.util;
+
+/**
+ * RSA加密工具类 - 与德惠邮箱SSO文档保持一致
+ *
+ * @deprecated 已下沉到基座 {@link com.malk.util.crypto.RSACrypt},请改 import 到新位置。
+ * 本类保留仅为兼容既有调用方,内部方法直接委托新类。
+ */
+@Deprecated
+public class RSACrypt {
+
+    /**
+     * @deprecated 使用 {@link com.malk.util.crypto.RSACrypt#encrypt(String, String)}
+     */
+    @Deprecated
+    public static String encrypt(String str, String publicKey) throws Exception {
+        return com.malk.util.crypto.RSACrypt.encrypt(str, publicKey);
+    }
+
+    /**
+     * @deprecated 使用 {@link com.malk.util.crypto.RSACrypt#toUrlSafe(String)}
+     */
+    @Deprecated
+    public static String toUrlSafe(String base64String) {
+        return com.malk.util.crypto.RSACrypt.toUrlSafe(base64String);
+    }
+}

+ 33 - 0
mjava-guangming/src/main/resources/application-prod.yml.example

@@ -0,0 +1,33 @@
+# mjava-guangming 生产配置模板
+# 使用方式:复制为 application-prod.yml 并填入真实值;真实文件已被 .gitignore 排除
+
+server:
+  port: 9003
+  servlet:
+    context-path: /api/gm
+
+spel:
+  scheduling: false
+  multiSource: false
+
+spring:
+  datasource:
+    hikari:
+      connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    username: ${DB_USERNAME}
+    password: ${DB_PASSWORD}
+    url: ${DB_URL}
+  jpa:
+    database: MYSQL
+    database-platform: org.hibernate.dialect.MySQL57Dialect
+
+# 光明配置
+guangming:
+  dingtalk:
+    appKey: ${DINGTALK_APP_KEY}
+    appSecret: ${DINGTALK_APP_SECRET}
+    corpId: ${DINGTALK_CORP_ID}
+  mailSso:
+    ssoUrl: ${MAIL_SSO_URL}
+    publicKey: ${MAIL_SSO_PUBLIC_KEY}

+ 138 - 0
mjava-guangming/src/main/resources/static/sso.html

@@ -0,0 +1,138 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>邮箱登录</title>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body {
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            height: 100vh;
+            background: #f5f5f5;
+            font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
+        }
+        .container {
+            text-align: center;
+            padding: 40px;
+        }
+        .tip-icon {
+            width: 48px;
+            height: 48px;
+            margin: 0 auto 16px;
+            border-radius: 50%;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            font-size: 24px;
+        }
+        .tip-icon.warn { background: #fff3e0; }
+        .spinner {
+            width: 36px;
+            height: 36px;
+            margin: 0 auto 16px;
+            border: 3px solid #e0e0e0;
+            border-top-color: #1890ff;
+            border-radius: 50%;
+            animation: spin 0.8s linear infinite;
+        }
+        @keyframes spin {
+            to { transform: rotate(360deg); }
+        }
+        .tip-text {
+            font-size: 15px;
+            color: #666;
+            line-height: 1.6;
+        }
+    </style>
+</head>
+<body>
+    <!-- 页面内loading(兜底) -->
+    <div class="container" id="loading" style="display:none;">
+        <div class="spinner"></div>
+        <p class="tip-text" id="loadingText">正在登录邮箱...</p>
+    </div>
+
+    <!-- 非钉钉环境提示 -->
+    <div class="container" id="tipContainer" style="display:none;">
+        <div class="tip-icon warn">&#9888;</div>
+        <p class="tip-text">请在钉钉内打开此页面<br>浏览器不支持自动登录</p>
+    </div>
+
+    <script src="https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js"></script>
+    <script>
+        var CORP_ID = 'dingc54682076c69f0f2bc961a6cb783455b';
+
+        function isDingTalk() {
+            return /DingTalk/i.test(navigator.userAgent);
+        }
+
+        // 钉钉原生loading
+        function showLoading(text) {
+            dd.device.notification.showPreloader({ text: text || '正在跳转邮箱...' });
+        }
+
+        function hideLoading() {
+            dd.device.notification.hidePreloader();
+        }
+
+        // 钉钉原生toast提示
+        function showToast(text, icon) {
+            dd.device.notification.toast({ icon: icon || '', text: text, duration: 3 });
+        }
+
+        // 钉钉原生alert弹窗(用于错误提示)
+        function showAlert(title, message) {
+            hideLoading();
+            dd.device.notification.alert({
+                title: title || '提示',
+                message: message,
+                buttonName: '我知道了'
+            });
+        }
+
+        function updateLoading(text) {
+            document.getElementById('loadingText').textContent = text;
+        }
+
+        if (!isDingTalk()) {
+            document.getElementById('tipContainer').style.display = 'block';
+        } else {
+            // 页面内loading + 钉钉原生loading
+            document.getElementById('loading').style.display = 'block';
+            showLoading('正在登录邮箱...');
+
+            try {
+                var result = dd.runtime.permission.requestAuthCode({
+                    corpId: CORP_ID,
+                    onSuccess: function(res) {
+                        updateLoading('正在跳转邮箱系统...');
+                        showLoading('正在跳转邮箱系统...');
+                        window.location.href = '/api/gm/sso/mail?authCode=' + res.code;
+                    },
+                    onFail: function(err) {
+                        showAlert('授权失败', err.errorMessage || JSON.stringify(err));
+                    }
+                });
+                // 新版JSAPI返回Promise
+                if (result && typeof result.then === 'function') {
+                    result.then(function(res) {
+                        updateLoading('正在跳转邮箱系统...');
+                        showLoading('正在跳转邮箱系统...');
+                        var code = res.code || (res.result && res.result.code);
+                        if (code) {
+                            window.location.href = '/api/gm/sso/mail?authCode=' + code;
+                        }
+                    }).catch(function(err) {
+                        showAlert('授权失败', err.errorMessage || JSON.stringify(err));
+                    });
+                }
+            } catch(e) {
+                showAlert('系统异常', e.message);
+            }
+        }
+    </script>
+</body>
+</html>

+ 113 - 0
mjava-mcli/pom.xml

@@ -0,0 +1,113 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>java-mcli</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>mjava-mcli</artifactId>
+    <description>java mcli 验证</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <repositories>
+        <!-- webService 依赖 -->
+        <repository>
+            <id>com.e-iceblue</id>
+            <name>e-iceblue</name>
+            <url>http://repo.e-iceblue.cn/repository/maven-public/</url>
+        </repository>
+    </repositories>
+
+    <dependencies>
+        <!-- 核心模块-->
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>${mjava.version}</version>
+        </dependency>
+
+        <!-- url转pdf -->
+        <dependency>
+            <groupId>org.xhtmlrenderer</groupId>
+            <artifactId>flying-saucer-pdf-itext5</artifactId>
+        </dependency>
+
+        <!-- jsp: tomcat-embed-jasper 需要在子项目内引用 -->
+        <dependency>
+            <groupId>org.apache.tomcat.embed</groupId>
+            <artifactId>tomcat-embed-jasper</artifactId>
+        </dependency>
+        <!-- servlet -->
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>javax.servlet-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>javax.servlet.jsp</groupId>
+            <artifactId>javax.servlet.jsp-api</artifactId>
+        </dependency>
+
+        <!-- MongoDB 驱动 -->
+        <dependency>
+            <groupId>org.mongodb</groupId>
+            <artifactId>mongo-java-driver</artifactId>
+            <version>3.1.0</version>
+        </dependency>
+        <!-- MongoDB jpa 操作 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-mongodb</artifactId>
+            <version>2.2.13.RELEASE</version>
+        </dependency>
+
+        <!--    local test    -->
+
+        <!-- CXF webservice -->
+        <dependency>
+            <groupId>org.apache.cxf</groupId>
+            <artifactId>cxf-spring-boot-starter-jaxws</artifactId>
+            <version>3.4.0</version>
+        </dependency>
+
+        <!-- 文档审阅, 转换依赖  -->
+<!--        <dependency>-->
+<!--            <groupId>e-iceblue</groupId>-->
+<!--            <artifactId>spire.doc.free</artifactId>-->
+<!--            <version>3.9.0</version>-->
+<!--        </dependency>-->
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <!-- 如果没有该配置,devtools不会生效: 打包时关闭 -->
+                    <fork>false</fork>
+                    <!-- 避免中文乱码 -->
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <!-- 允许生成可运行jar -->
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 32 - 0
mjava-mcli/src/main/java/com/malk/mcli/Boot.java

@@ -0,0 +1,32 @@
+package com.malk.mcli;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import javax.persistence.EntityManager;
+
+/**
+ * corp项目: 扫描公共模块
+ * -
+ * 若是无需数据库模块, 配置无效地址也可启动, 引入mjava不支持直接 @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) 配置
+ * 需要配置 jpa.hibernate.ddl-auto 为 none. 标识对表没有任何操作. 若不设置为 none, flyway.enabled 配置会无效, 在没有数库连接情况下程序无法启动
+ */
+@EnableJpaAuditing
+@SpringBootApplication(scanBasePackages = {"com.malk"})
+public class Boot {
+
+    public static void main(String... args) {
+        SpringApplication.run(Boot.class, args);
+    }
+
+    /**
+     * 让Spring管理JPAQueryFactory [不使用Qualifier详见mjava-Boot]
+     */
+    @Bean
+    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
+        return new JPAQueryFactory(entityManager);
+    }
+}

+ 37 - 0
mjava-mcli/src/main/resources/application-prod.yml.example

@@ -0,0 +1,37 @@
+# mjava-mcli 生产配置模板
+# 使用方式:复制为 application-prod.yml 并填入真实值;真实文件已被 .gitignore 排除
+
+server:
+  port: 9001
+  servlet:
+    context-path: /api/mcli
+
+spel:
+  scheduling: false       # 定时任务是否执行
+  multiSource: false      # 是否多数据源配置
+
+spring:
+  datasource:
+    hikari:
+      connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    username: ${DB_USERNAME}
+    password: ${DB_PASSWORD}
+    url: ${DB_URL}
+  jpa:
+    database: MYSQL
+    database-platform: org.hibernate.dialect.MySQL57Dialect
+  # MongoDB(若需要)
+  data:
+    mongodb:
+      uri: ${MONGODB_URI}
+
+# dingtalk
+dingtalk:
+  agentId: ${DINGTALK_AGENT_ID}
+  appKey: ${DINGTALK_APP_KEY}
+  appSecret: ${DINGTALK_APP_SECRET}
+  corpId: ${DINGTALK_CORP_ID}
+  aesKey: ${DINGTALK_AES_KEY}
+  token: ${DINGTALK_TOKEN}
+  operator: ${DINGTALK_OPERATOR}

+ 51 - 0
mjava-shunfeng/pom.xml

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>java-mcli</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>mjava-shunfeng</artifactId>
+    <description>顺丰集成:腾讯会议 / Zoom / 钉钉日程联动</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+        <!-- 核心模块 -->
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>${mjava.version}</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <fork>false</fork>
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 30 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/Boot.java

@@ -0,0 +1,30 @@
+package com.malk.shunfeng;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import javax.persistence.EntityManager;
+
+/**
+ * 顺丰集成子模块启动类
+ * - 扫描公共模块 com.malk(含 mjava 中的 DDClient / DDClient_Schedule / DingCallbackCrypto 等)
+ */
+@EnableJpaAuditing
+@SpringBootApplication(scanBasePackages = {"com.malk"})
+public class Boot {
+
+    public static void main(String... args) {
+        SpringApplication.run(Boot.class, args);
+    }
+
+    /**
+     * 让Spring管理JPAQueryFactory
+     */
+    @Bean
+    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
+        return new JPAQueryFactory(entityManager);
+    }
+}

+ 93 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/controller/DdCallbackController.java

@@ -0,0 +1,93 @@
+package com.malk.shunfeng.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.crypto.DingCallbackCrypto;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 钉钉日程回调 Controller
+ * -
+ * 接收钉钉 calendar 事件回调并解析(复用 DingCallbackCrypto + DDConf)
+ * 事件类型: calendar_event_created / calendar_event_updated / calendar_event_deleted
+ */
+@Slf4j
+@RestController
+@RequestMapping("/sf/dd/callback")
+public class DdCallbackController {
+
+    /** 钉钉日程事件类型常量 */
+    private static final String CALENDAR_EVENT_CREATED = "calendar_event_created";
+    private static final String CALENDAR_EVENT_UPDATED = "calendar_event_updated";
+    private static final String CALENDAR_EVENT_DELETED = "calendar_event_deleted";
+
+    @Autowired
+    private DDConf ddConf;
+
+    /**
+     * 接收钉钉日程回调
+     * -
+     * 复用 DingCallbackCrypto 解密请求体,与 mjava 中 DDCallbackController 模式一致
+     * 返回加密响应(钉钉要求 3s 内返回,处理耗时任务请异步)
+     */
+    @SneakyThrows
+    @PostMapping
+    public Map<String, String> callback(
+            @RequestParam(value = "signature", required = false) String signature,
+            @RequestParam(value = "timestamp", required = false) String timestamp,
+            @RequestParam(value = "nonce", required = false) String nonce,
+            @RequestBody(required = false) JSONObject json) {
+
+        // ppExt: corpId 使用 appKey(企业自建应用事件订阅场景)
+        DingCallbackCrypto callbackCrypto = new DingCallbackCrypto(
+                ddConf.getToken(), ddConf.getAesKey(), ddConf.getAppKey()
+        );
+
+        // 解密请求体
+        String decryptMsg = callbackCrypto.getDecryptMsg(
+                signature, timestamp, nonce, json.getString("encrypt")
+        );
+        JSONObject eventJson = JSON.parseObject(decryptMsg);
+        log.info("[SF] 钉钉日程回调, eventJson={}", eventJson);
+
+        // 构建加密成功响应(钉钉要求返回加密格式)
+        Map<String, String> success = callbackCrypto.getEncryptedMap(
+                DDConf.CALLBACK_RESPONSE,
+                System.currentTimeMillis(),
+                DingCallbackCrypto.Utils.getRandomStr(8)
+        );
+
+        String eventType = eventJson.getString("EventType");
+
+        // 验证注册地址
+        if (DDConf.CALLBACK_CHECK.equals(eventType)) {
+            log.info("[SF][DD] 验证注册回调");
+            return success;
+        }
+
+        // fixme: 日程事件处理逻辑在此扩展,当前仅记录日志
+        if (CALENDAR_EVENT_CREATED.equals(eventType)) {
+            log.info("[SF][DD] 日程创建事件, {}", eventJson);
+            return success;
+        }
+
+        if (CALENDAR_EVENT_UPDATED.equals(eventType)) {
+            log.info("[SF][DD] 日程更新事件, {}", eventJson);
+            return success;
+        }
+
+        if (CALENDAR_EVENT_DELETED.equals(eventType)) {
+            log.info("[SF][DD] 日程删除事件, {}", eventJson);
+            return success;
+        }
+
+        log.info("[SF][DD] 未处理的回调事件类型: {}, body={}", eventType, eventJson);
+        return success;
+    }
+}

+ 95 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/controller/MeetingController.java

@@ -0,0 +1,95 @@
+package com.malk.shunfeng.controller;
+
+import com.malk.server.common.McR;
+import com.malk.shunfeng.service.MeetingService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 会议统一 Controller(腾讯会议 / Zoom)
+ * -
+ * context-path: /api/sf  → 接口前缀: /api/sf/sf/meeting
+ */
+@Slf4j
+@RestController
+@RequestMapping("/sf/meeting")
+public class MeetingController {
+
+    @Autowired
+    private MeetingService meetingService;
+
+    /**
+     * 创建会议
+     * POST /sf/meeting/create?platform=TX&userId=xxx&dingtalkUserId=xxx
+     *
+     * @param platform       TX 或 ZOOM
+     * @param userId         腾讯会议用户 ID(ZOOM 可不传)
+     * @param dingtalkUserId 钉钉用户 ID(用于同步日程,可选)
+     * @param body           会议创建参数
+     */
+    @PostMapping("/create")
+    public McR create(
+            @RequestParam String platform,
+            @RequestParam(required = false) String userId,
+            @RequestParam(required = false) String dingtalkUserId,
+            @RequestBody Map body) {
+        log.info("[SF] 创建会议, platform={}, userId={}, dingtalkUserId={}", platform, userId, dingtalkUserId);
+        return meetingService.createMeeting(platform, userId, dingtalkUserId, body);
+    }
+
+    /**
+     * 更新会议
+     * PUT /sf/meeting/{meetingId}?platform=TX&userId=xxx
+     *
+     * @param meetingId 会议 ID
+     * @param platform  TX 或 ZOOM
+     * @param userId    腾讯会议用户 ID(ZOOM 可不传)
+     * @param body      修改内容
+     */
+    @PutMapping("/{meetingId}")
+    public McR update(
+            @PathVariable String meetingId,
+            @RequestParam String platform,
+            @RequestParam(required = false) String userId,
+            @RequestBody Map body) {
+        log.info("[SF] 更新会议, platform={}, meetingId={}", platform, meetingId);
+        return meetingService.updateMeeting(platform, meetingId, userId, body);
+    }
+
+    /**
+     * 取消/删除会议
+     * DELETE /sf/meeting/{meetingId}?platform=TX&userId=xxx
+     *
+     * @param meetingId 会议 ID
+     * @param platform  TX 或 ZOOM
+     * @param userId    腾讯会议用户 ID(ZOOM 可不传)
+     */
+    @DeleteMapping("/{meetingId}")
+    public McR cancel(
+            @PathVariable String meetingId,
+            @RequestParam String platform,
+            @RequestParam(required = false) String userId) {
+        log.info("[SF] 取消会议, platform={}, meetingId={}", platform, meetingId);
+        return meetingService.cancelMeeting(platform, meetingId, userId);
+    }
+
+    /**
+     * 查询会议详情
+     * GET /sf/meeting/{meetingId}?platform=TX&userId=xxx
+     *
+     * @param meetingId 会议 ID
+     * @param platform  TX 或 ZOOM
+     * @param userId    腾讯会议用户 ID(ZOOM 可不传)
+     */
+    @GetMapping("/{meetingId}")
+    public McR get(
+            @PathVariable String meetingId,
+            @RequestParam String platform,
+            @RequestParam(required = false) String userId) {
+        log.info("[SF] 查询会议, platform={}, meetingId={}", platform, meetingId);
+        return meetingService.getMeeting(platform, meetingId, userId);
+    }
+}

+ 76 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/server/txmeeting/TxMeetingConf.java

@@ -0,0 +1,76 @@
+package com.malk.shunfeng.server.txmeeting;
+
+import com.malk.utils.UtilMap;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 腾讯会议配置 & 签名工具
+ * -
+ * 签名规范: signContent = "{METHOD}\n{path}\n{timestamp}\n{nonce}\n{body}"
+ * signature = Base64( HMAC-SHA256(secretKey, signContent) )
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "txmeeting")
+public class TxMeetingConf {
+
+    private String secretId;
+
+    private String secretKey;
+
+    /** SDK ID(企业 ID) */
+    private String sdkId;
+
+    private String apiHost;
+
+    /**
+     * 生成 HMAC-SHA256 签名
+     *
+     * @param method    HTTP 方法,如 POST / GET / PUT / DELETE
+     * @param path      URL 路径,如 /v1/meetings(不含 host)
+     * @param timestamp 秒级时间戳
+     * @param nonce     随机字符串
+     * @param body      请求体 JSON 字符串,无请求体传空字符串
+     */
+    public String buildSignature(String method, String path, long timestamp, String nonce, String body) {
+        try {
+            // fixme: body 为 null 或 GET 请求时传空字符串
+            String content = body == null ? "" : body;
+            String signStr = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + content;
+            Mac mac = Mac.getInstance("HmacSHA256");
+            SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+            mac.init(keySpec);
+            byte[] rawHmac = mac.doFinal(signStr.getBytes(StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(rawHmac);
+        } catch (Exception e) {
+            throw new RuntimeException("腾讯会议签名失败", e);
+        }
+    }
+
+    /**
+     * 构建腾讯会议请求 Header(每次调用动态生成签名)
+     *
+     * @param method HTTP 方法
+     * @param path   URL 路径(不含 host)
+     * @param body   请求体 JSON 字符串
+     */
+    public Map initHeaders(String method, String path, String body) {
+        long timestamp = System.currentTimeMillis() / 1000;
+        // fixme: nonce 要求随机字符串,使用 UUID 去横线保证唯一性
+        String nonce = UUID.randomUUID().toString().replace("-", "");
+        String signature = buildSignature(method, path, timestamp, nonce, body);
+        return UtilMap.map(
+                "X-TC-Key, X-TC-Timestamp, X-TC-Nonce, X-TC-Signature, X-TC-Registered",
+                secretId, String.valueOf(timestamp), nonce, signature, "1"
+        );
+    }
+}

+ 38 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/server/txmeeting/TxMeetingR.java

@@ -0,0 +1,38 @@
+package com.malk.shunfeng.server.txmeeting;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 腾讯会议 API 响应对象
+ * -
+ * 成功:返回 meeting_info_list
+ * 失败:返回 error_info.error_code != 0
+ */
+@Data
+public class TxMeetingR extends VenR {
+
+    @JSONField(name = "meeting_info_list")
+    private List<Map<String, Object>> meetingInfoList;
+
+    @JSONField(name = "error_info")
+    private Map<String, Object> errorInfo;
+
+    /**
+     * 断言腾讯会议请求是否成功
+     * fixme: 腾讯会议 error_code=0 表示成功,非 0 或存在 error_info 表示失败
+     */
+    @Override
+    public void assertSuccess() {
+        if (errorInfo != null && !errorInfo.isEmpty()) {
+            Object code = errorInfo.get("error_code");
+            Object msg = errorInfo.get("message");
+            McException.assertException(true, String.valueOf(code), String.valueOf(msg), "txmeeting");
+        }
+    }
+}

+ 28 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/server/zoom/ZoomConf.java

@@ -0,0 +1,28 @@
+package com.malk.shunfeng.server.zoom;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Zoom OAuth 2.0 Account Credentials 配置
+ * -
+ * Token 获取: POST {oauthUrl}?grant_type=account_credentials&account_id={accountId}
+ * Basic Auth: clientId:clientSecret
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "zoom")
+public class ZoomConf {
+
+    private String accountId;
+
+    private String clientId;
+
+    private String clientSecret;
+
+    private String apiHost;
+
+    /** Zoom OAuth Token 获取地址 */
+    private String oauthUrl;
+}

+ 45 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/server/zoom/ZoomR.java

@@ -0,0 +1,45 @@
+package com.malk.shunfeng.server.zoom;
+
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+
+/**
+ * Zoom 会议 API 响应对象
+ * -
+ * 成功:返回 id/topic/join_url 等字段
+ * 失败:返回 code/message
+ */
+@Data
+public class ZoomR extends VenR {
+
+    /** 会议 ID */
+    private String id;
+
+    private String topic;
+
+    private String join_url;
+
+    private String start_url;
+
+    private String password;
+
+    private String start_time;
+
+    private Integer duration;
+
+    /** 错误码(Zoom 错误时返回) */
+    private Integer code;
+
+    /** 错误描述 */
+    private String message;
+
+    /**
+     * 断言 Zoom 请求是否成功
+     * fixme: Zoom API 错误时返回 code 字段(非 null),成功无 code 字段
+     */
+    @Override
+    public void assertSuccess() {
+        McException.assertException(code != null && code != 0, String.valueOf(code), message, "zoom");
+    }
+}

+ 49 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/service/MeetingService.java

@@ -0,0 +1,49 @@
+package com.malk.shunfeng.service;
+
+import com.malk.server.common.McR;
+
+import java.util.Map;
+
+/**
+ * 会议统一服务接口(路由 TX / ZOOM)
+ */
+public interface MeetingService {
+
+    /**
+     * 创建会议
+     *
+     * @param platform       平台:TX(腾讯会议)或 ZOOM
+     * @param userId         平台用户 ID(腾讯会议操作人)
+     * @param dingtalkUserId 钉钉用户 ID(用于同步钉钉日程)
+     * @param body           创建参数
+     */
+    McR createMeeting(String platform, String userId, String dingtalkUserId, Map body);
+
+    /**
+     * 更新会议
+     *
+     * @param platform  平台
+     * @param meetingId 会议 ID
+     * @param userId    平台用户 ID
+     * @param body      修改内容
+     */
+    McR updateMeeting(String platform, String meetingId, String userId, Map body);
+
+    /**
+     * 取消/删除会议
+     *
+     * @param platform  平台
+     * @param meetingId 会议 ID
+     * @param userId    平台用户 ID(腾讯会议需要)
+     */
+    McR cancelMeeting(String platform, String meetingId, String userId);
+
+    /**
+     * 查询会议详情
+     *
+     * @param platform  平台
+     * @param meetingId 会议 ID
+     * @param userId    平台用户 ID(腾讯会议需要)
+     */
+    McR getMeeting(String platform, String meetingId, String userId);
+}

+ 44 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/service/TxMeetingClient.java

@@ -0,0 +1,44 @@
+package com.malk.shunfeng.service;
+
+import com.malk.shunfeng.server.txmeeting.TxMeetingR;
+
+import java.util.Map;
+
+/**
+ * 腾讯会议 API 客户端接口
+ */
+public interface TxMeetingClient {
+
+    /**
+     * 创建会议
+     *
+     * @param userId 操作人 userId
+     * @param body   请求体(subject/start_time/end_time/type 等)
+     */
+    TxMeetingR createMeeting(String userId, Map body);
+
+    /**
+     * 修改会议
+     *
+     * @param meetingId 会议 ID
+     * @param userId    操作人 userId
+     * @param body      修改内容
+     */
+    TxMeetingR updateMeeting(String meetingId, String userId, Map body);
+
+    /**
+     * 取消会议
+     *
+     * @param meetingId 会议 ID
+     * @param userId    操作人 userId
+     */
+    TxMeetingR cancelMeeting(String meetingId, String userId);
+
+    /**
+     * 查询会议详情
+     *
+     * @param meetingId 会议 ID
+     * @param userId    操作人 userId
+     */
+    TxMeetingR getMeeting(String meetingId, String userId);
+}

+ 40 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/service/ZoomClient.java

@@ -0,0 +1,40 @@
+package com.malk.shunfeng.service;
+
+import com.malk.shunfeng.server.zoom.ZoomR;
+
+import java.util.Map;
+
+/**
+ * Zoom 会议 API 客户端接口
+ */
+public interface ZoomClient {
+
+    /**
+     * 创建会议
+     *
+     * @param body 请求体(topic/start_time/duration/agenda 等)
+     */
+    ZoomR createMeeting(Map body);
+
+    /**
+     * 更新会议(PATCH,响应 204 无内容)
+     *
+     * @param meetingId Zoom 会议 ID
+     * @param body      修改内容
+     */
+    void updateMeeting(String meetingId, Map body);
+
+    /**
+     * 删除会议
+     *
+     * @param meetingId Zoom 会议 ID
+     */
+    void deleteMeeting(String meetingId);
+
+    /**
+     * 查询会议详情
+     *
+     * @param meetingId Zoom 会议 ID
+     */
+    ZoomR getMeeting(String meetingId);
+}

+ 176 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/service/impl/MeetingServiceImpl.java

@@ -0,0 +1,176 @@
+package com.malk.shunfeng.service.impl;
+
+import com.malk.server.common.McException;
+import com.malk.server.common.McR;
+import com.malk.shunfeng.server.txmeeting.TxMeetingR;
+import com.malk.shunfeng.server.zoom.ZoomR;
+import com.malk.shunfeng.service.MeetingService;
+import com.malk.shunfeng.service.TxMeetingClient;
+import com.malk.shunfeng.service.ZoomClient;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Schedule;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 会议统一服务实现
+ * -
+ * 路由 TX(腾讯会议) / ZOOM
+ * 成功后调 DDClient_Schedule.eventsSchedule() 同步钉钉日程(失败不阻断主流程)
+ */
+@Slf4j
+@Service
+public class MeetingServiceImpl implements MeetingService {
+
+    /** 平台常量 */
+    private static final String PLATFORM_TX = "TX";
+    private static final String PLATFORM_ZOOM = "ZOOM";
+
+    @Autowired
+    private TxMeetingClient txMeetingClient;
+
+    @Autowired
+    private ZoomClient zoomClient;
+
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private DDClient_Schedule ddClient_Schedule;
+
+    /**
+     * 创建会议
+     * 成功后同步钉钉日程(失败不影响主流程)
+     */
+    @Override
+    public McR createMeeting(String platform, String userId, String dingtalkUserId, Map body) {
+        assertPlatform(platform);
+        if (PLATFORM_TX.equalsIgnoreCase(platform)) {
+            TxMeetingR r = txMeetingClient.createMeeting(userId, body);
+            // ppExt: 创建成功后同步钉钉日程,失败仅记录日志
+            syncDingtalkSchedule(r, dingtalkUserId, body);
+            return McR.success(r);
+        }
+        // ZOOM
+        ZoomR r = zoomClient.createMeeting(body);
+        syncDingtalkScheduleZoom(r, dingtalkUserId, body);
+        return McR.success(r);
+    }
+
+    /**
+     * 更新会议
+     */
+    @Override
+    public McR updateMeeting(String platform, String meetingId, String userId, Map body) {
+        assertPlatform(platform);
+        if (PLATFORM_TX.equalsIgnoreCase(platform)) {
+            TxMeetingR r = txMeetingClient.updateMeeting(meetingId, userId, body);
+            return McR.success(r);
+        }
+        zoomClient.updateMeeting(meetingId, body);
+        return McR.success();
+    }
+
+    /**
+     * 取消/删除会议
+     */
+    @Override
+    public McR cancelMeeting(String platform, String meetingId, String userId) {
+        assertPlatform(platform);
+        if (PLATFORM_TX.equalsIgnoreCase(platform)) {
+            TxMeetingR r = txMeetingClient.cancelMeeting(meetingId, userId);
+            return McR.success(r);
+        }
+        zoomClient.deleteMeeting(meetingId);
+        return McR.success();
+    }
+
+    /**
+     * 查询会议详情
+     */
+    @Override
+    public McR getMeeting(String platform, String meetingId, String userId) {
+        assertPlatform(platform);
+        if (PLATFORM_TX.equalsIgnoreCase(platform)) {
+            TxMeetingR r = txMeetingClient.getMeeting(meetingId, userId);
+            return McR.success(r);
+        }
+        ZoomR r = zoomClient.getMeeting(meetingId);
+        return McR.success(r);
+    }
+
+    /**
+     * 创建腾讯会议后同步钉钉日程
+     * fixme: meetingInfoList 第一条即为新创建的会议,取 start_time/end_time 同步钉钉日程
+     */
+    private void syncDingtalkSchedule(TxMeetingR txR, String dingtalkUserId, Map body) {
+        try {
+            if (StringUtils.isBlank(dingtalkUserId)) return;
+            List<Map<String, Object>> infoList = txR.getMeetingInfoList();
+            if (infoList == null || infoList.isEmpty()) return;
+            Map<String, Object> meetingInfo = infoList.get(0);
+            Map scheduleBody = buildScheduleBody(meetingInfo, body);
+            String accessToken = ddClient.getAccessToken();
+            ddClient_Schedule.eventsSchedule(accessToken, dingtalkUserId, scheduleBody);
+            log.info("[SF] 腾讯会议同步钉钉日程成功, dingtalkUserId={}", dingtalkUserId);
+        } catch (Exception e) {
+            // ppExt: 同步钉钉日程失败不阻断会议创建结果
+            log.error("[SF] 同步钉钉日程失败(腾讯会议), error={}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 创建 Zoom 会议后同步钉钉日程
+     */
+    private void syncDingtalkScheduleZoom(ZoomR zoomR, String dingtalkUserId, Map body) {
+        try {
+            if (StringUtils.isBlank(dingtalkUserId)) return;
+            Map scheduleBody = buildScheduleBodyZoom(zoomR, body);
+            String accessToken = ddClient.getAccessToken();
+            ddClient_Schedule.eventsSchedule(accessToken, dingtalkUserId, scheduleBody);
+            log.info("[SF] Zoom 会议同步钉钉日程成功, dingtalkUserId={}", dingtalkUserId);
+        } catch (Exception e) {
+            log.error("[SF] 同步钉钉日程失败(Zoom), error={}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 构建腾讯会议 → 钉钉日程参数
+     * fixme: startTime/endTime 需为 ISO-8601 格式,钉钉日程要求 timeZone 字段
+     */
+    private Map buildScheduleBody(Map<String, Object> meetingInfo, Map body) {
+        String subject = String.valueOf(meetingInfo.getOrDefault("subject", body.getOrDefault("subject", "会议")));
+        // ppExt: 腾讯会议时间戳为秒,需转为 ISO-8601 格式(如 2024-01-01T10:00:00+08:00)
+        Object startTimeRaw = meetingInfo.getOrDefault("start_time", body.get("start_time"));
+        Object endTimeRaw = meetingInfo.getOrDefault("end_time", body.get("end_time"));
+        Map start = UtilMap.map("dateTime, timeZone", String.valueOf(startTimeRaw), "Asia/Shanghai");
+        Map end = UtilMap.map("dateTime, timeZone", String.valueOf(endTimeRaw), "Asia/Shanghai");
+        return UtilMap.map("summary, start, end", subject, start, end);
+    }
+
+    /**
+     * 构建 Zoom → 钉钉日程参数
+     * fixme: Zoom start_time 已为 ISO-8601 格式
+     */
+    private Map buildScheduleBodyZoom(ZoomR zoomR, Map body) {
+        String subject = zoomR.getTopic() != null ? zoomR.getTopic() : String.valueOf(body.getOrDefault("topic", "Zoom 会议"));
+        String startTime = StringUtils.isNotBlank(zoomR.getStart_time()) ? zoomR.getStart_time() : String.valueOf(body.get("start_time"));
+        Map start = UtilMap.map("dateTime, timeZone", startTime, "Asia/Shanghai");
+        // fixme: Zoom 无 end_time,通过 start_time + duration(分钟)推算
+        Map end = UtilMap.map("dateTime, timeZone", startTime, "Asia/Shanghai");
+        return UtilMap.map("summary, start, end", subject, start, end);
+    }
+
+    private void assertPlatform(String platform) {
+        McException.assertParamException(
+                !PLATFORM_TX.equalsIgnoreCase(platform) && !PLATFORM_ZOOM.equalsIgnoreCase(platform),
+                "platform 参数非法,支持 TX / ZOOM"
+        );
+    }
+}

+ 109 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/service/impl/TxMeetingClientImpl.java

@@ -0,0 +1,109 @@
+package com.malk.shunfeng.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.malk.server.common.McException;
+import com.malk.shunfeng.server.txmeeting.TxMeetingConf;
+import com.malk.shunfeng.server.txmeeting.TxMeetingR;
+import com.malk.shunfeng.service.TxMeetingClient;
+import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+/**
+ * 腾讯会议 API 客户端实现
+ * -
+ * 每次请求动态生成签名 Header
+ * API 文档: https://cloud.tencent.com/document/product/1095
+ */
+@Slf4j
+@Service
+public class TxMeetingClientImpl implements TxMeetingClient {
+
+    @Autowired
+    private TxMeetingConf txMeetingConf;
+
+    /** 腾讯会议创建会议路径 */
+    private static final String PATH_MEETINGS = "/v1/meetings";
+
+    /**
+     * 创建会议
+     * POST /v1/meetings
+     */
+    @Override
+    public TxMeetingR createMeeting(String userId, Map body) {
+        // fixme: 腾讯会议需要在 body 中传 userid 和 instanceid
+        body.put("userid", userId);
+        body.put("instanceid", 1);
+        String bodyJson = toJson(body);
+        Map header = txMeetingConf.initHeaders("POST", PATH_MEETINGS, bodyJson);
+        String url = txMeetingConf.getApiHost() + PATH_MEETINGS;
+        String rsp = UtilHttp.doPost(url, header, null, body);
+        return parseAndAssert(rsp, "创建腾讯会议失败");
+    }
+
+    /**
+     * 修改会议
+     * PUT /v1/meetings/{meetingId}
+     */
+    @Override
+    public TxMeetingR updateMeeting(String meetingId, String userId, Map body) {
+        body.put("userid", userId);
+        body.put("instanceid", 1);
+        String path = PATH_MEETINGS + "/" + meetingId;
+        String bodyJson = toJson(body);
+        Map header = txMeetingConf.initHeaders("PUT", path, bodyJson);
+        String url = txMeetingConf.getApiHost() + path;
+        String rsp = UtilHttp.doPut(url, header, null, body);
+        return parseAndAssert(rsp, "修改腾讯会议失败");
+    }
+
+    /**
+     * 取消会议
+     * POST /v1/meetings/{meetingId}/cancel
+     */
+    @Override
+    public TxMeetingR cancelMeeting(String meetingId, String userId) {
+        String path = PATH_MEETINGS + "/" + meetingId + "/cancel";
+        Map body = UtilMap.map("userid, instanceid", userId, 1);
+        String bodyJson = toJson(body);
+        Map header = txMeetingConf.initHeaders("POST", path, bodyJson);
+        String url = txMeetingConf.getApiHost() + path;
+        String rsp = UtilHttp.doPost(url, header, null, body);
+        return parseAndAssert(rsp, "取消腾讯会议失败");
+    }
+
+    /**
+     * 查询会议详情
+     * GET /v1/meetings/{meetingId}?userid={userId}&instanceid=1
+     */
+    @Override
+    public TxMeetingR getMeeting(String meetingId, String userId) {
+        String path = PATH_MEETINGS + "/" + meetingId;
+        Map header = txMeetingConf.initHeaders("GET", path, "");
+        String url = txMeetingConf.getApiHost() + path;
+        Map param = UtilMap.map("userid, instanceid", userId, 1);
+        String rsp = UtilHttp.doGet(url, header, param);
+        return parseAndAssert(rsp, "查询腾讯会议失败");
+    }
+
+    /**
+     * 解析响应并断言成功
+     */
+    private TxMeetingR parseAndAssert(String rsp, String errMsg) {
+        log.debug("[TxMeeting] 响应: {}", rsp);
+        McException.assertException(StringUtils.isBlank(rsp), "TX_RSP_NULL", errMsg);
+        TxMeetingR r = JSON.parseObject(rsp, TxMeetingR.class);
+        r.assertSuccess();
+        return r;
+    }
+
+    private String toJson(Map body) {
+        return JSON.toJSONString(body, SerializerFeature.WriteMapNullValue);
+    }
+}

+ 137 - 0
mjava-shunfeng/src/main/java/com/malk/shunfeng/service/impl/ZoomClientImpl.java

@@ -0,0 +1,137 @@
+package com.malk.shunfeng.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.common.McException;
+import com.malk.shunfeng.server.zoom.ZoomConf;
+import com.malk.shunfeng.server.zoom.ZoomR;
+import com.malk.shunfeng.service.ZoomClient;
+import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilMap;
+import com.malk.utils.UtilToken;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+/**
+ * Zoom 会议 API 客户端实现
+ * -
+ * 使用 OAuth 2.0 Account Credentials,Token 通过 UtilToken 缓存复用(key="sf-zoom-token")
+ */
+@Slf4j
+@Service
+public class ZoomClientImpl implements ZoomClient {
+
+    @Autowired
+    private ZoomConf zoomConf;
+
+    /** UtilToken 缓存 key */
+    private static final String TOKEN_KEY = "sf-zoom-token";
+
+    /** Zoom 会议接口路径前缀 */
+    private static final String PATH_USERS_ME_MEETINGS = "/users/me/meetings";
+
+    /**
+     * 创建会议
+     * POST /users/me/meetings
+     */
+    @Override
+    public ZoomR createMeeting(Map body) {
+        String url = zoomConf.getApiHost() + PATH_USERS_ME_MEETINGS;
+        Map header = buildAuthHeader();
+        String rsp = UtilHttp.doPost(url, header, null, body);
+        return parseAndAssert(rsp, "创建 Zoom 会议失败");
+    }
+
+    /**
+     * 更新会议(PATCH,成功响应 204 No Content)
+     * PATCH /meetings/{meetingId}
+     * fixme: 204 响应无 body,parseAndAssert 不做调用
+     */
+    @Override
+    public void updateMeeting(String meetingId, Map body) {
+        String url = zoomConf.getApiHost() + "/meetings/" + meetingId;
+        Map header = buildAuthHeader();
+        String rsp = UtilHttp.doPatch(url, header, null, body);
+        // fixme: PATCH 成功返回 204 空响应,只打印日志,不解析
+        log.debug("[Zoom] updateMeeting 响应: {}", rsp);
+        if (StringUtils.isNotBlank(rsp)) {
+            ZoomR r = JSON.parseObject(rsp, ZoomR.class);
+            r.assertSuccess();
+        }
+    }
+
+    /**
+     * 删除会议
+     * DELETE /meetings/{meetingId}
+     */
+    @Override
+    public void deleteMeeting(String meetingId) {
+        String url = zoomConf.getApiHost() + "/meetings/" + meetingId;
+        Map header = buildAuthHeader();
+        String rsp = UtilHttp.doDelete(url, header, null, (Map) null);
+        log.debug("[Zoom] deleteMeeting 响应: {}", rsp);
+        if (StringUtils.isNotBlank(rsp)) {
+            ZoomR r = JSON.parseObject(rsp, ZoomR.class);
+            r.assertSuccess();
+        }
+    }
+
+    /**
+     * 查询会议详情
+     * GET /meetings/{meetingId}
+     */
+    @Override
+    public ZoomR getMeeting(String meetingId) {
+        String url = zoomConf.getApiHost() + "/meetings/" + meetingId;
+        Map header = buildAuthHeader();
+        String rsp = UtilHttp.doGet(url, header, null);
+        return parseAndAssert(rsp, "查询 Zoom 会议失败");
+    }
+
+    /**
+     * 获取 Access Token(缓存复用,过期自动刷新)
+     * POST {oauthUrl}?grant_type=account_credentials&account_id={accountId}
+     * Basic Auth: clientId:clientSecret
+     */
+    private String getAccessToken() {
+        // ppExt: 先从缓存取,命中则直接返回,避免重复申请
+        String cached = UtilToken.get(TOKEN_KEY);
+        if (StringUtils.isNotBlank(cached)) {
+            return cached;
+        }
+        Map param = UtilMap.map("grant_type, account_id", "account_credentials", zoomConf.getAccountId());
+        String rsp = UtilHttp.doRequest(
+                UtilHttp.METHOD.POST,
+                zoomConf.getOauthUrl(),
+                null, param, null, null,
+                zoomConf.getClientId(), zoomConf.getClientSecret()
+        );
+        McException.assertException(StringUtils.isBlank(rsp), "ZOOM_TOKEN_NULL", "Zoom Token 获取失败");
+        JSONObject tokenJson = JSON.parseObject(rsp);
+        String accessToken = tokenJson.getString("access_token");
+        Long expiresIn = tokenJson.getLong("expires_in");
+        McException.assertException(StringUtils.isBlank(accessToken), "ZOOM_TOKEN_EMPTY", "Zoom Token 为空");
+        // fixme: expiresIn 单位为秒,UtilToken.put 单位为毫秒,内部冗余 5s 容错
+        UtilToken.put(TOKEN_KEY, accessToken, expiresIn * 1000);
+        return accessToken;
+    }
+
+    /**
+     * 构建 Zoom Bearer Token 请求 Header
+     */
+    private Map buildAuthHeader() {
+        return UtilMap.map("Authorization", "Bearer " + getAccessToken());
+    }
+
+    private ZoomR parseAndAssert(String rsp, String errMsg) {
+        log.debug("[Zoom] 响应: {}", rsp);
+        McException.assertException(StringUtils.isBlank(rsp), "ZOOM_RSP_NULL", errMsg);
+        ZoomR r = JSON.parseObject(rsp, ZoomR.class);
+        r.assertSuccess();
+        return r;
+    }
+}

+ 48 - 0
mjava-shunfeng/src/main/resources/application-prod.yml.example

@@ -0,0 +1,48 @@
+# mjava-shunfeng 生产配置模板
+# 使用方式:复制为 application-prod.yml 并填入真实值;真实文件已被 .gitignore 排除
+
+server:
+  port: 9002
+  servlet:
+    context-path: /api/sf
+
+spel:
+  scheduling: false
+  multiSource: false
+
+spring:
+  datasource:
+    hikari:
+      connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    username: ${DB_USERNAME}
+    password: ${DB_PASSWORD}
+    url: ${DB_URL}
+  jpa:
+    database: MYSQL
+    database-platform: org.hibernate.dialect.MySQL57Dialect
+
+# 腾讯会议配置
+txmeeting:
+  secretId: ${TXMEETING_SECRET_ID}
+  secretKey: ${TXMEETING_SECRET_KEY}
+  sdkId: ${TXMEETING_SDK_APP_ID}
+  apiHost: https://api.meeting.qq.com
+
+# Zoom 配置
+zoom:
+  accountId: ${ZOOM_ACCOUNT_ID}
+  clientId: ${ZOOM_CLIENT_ID}
+  clientSecret: ${ZOOM_CLIENT_SECRET}
+  apiHost: https://api.zoom.us/v2
+  oauthUrl: https://zoom.us/oauth/token
+
+# dingtalk
+dingtalk:
+  agentId: ${DINGTALK_AGENT_ID}
+  appKey: ${DINGTALK_APP_KEY}
+  appSecret: ${DINGTALK_APP_SECRET}
+  corpId: ${DINGTALK_CORP_ID}
+  aesKey: ${DINGTALK_AES_KEY}
+  token: ${DINGTALK_TOKEN}
+  operator: ${DINGTALK_OPERATOR}

+ 51 - 0
mjava/pom.xml

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>java-mcli</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+    <!-- mjava版本, 不同java-cli项目区分底层依赖, 使用变量有警告 -->
+    <version>0.0.3</version>
+
+    <artifactId>mjava</artifactId>
+    <description>mjava framework</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <!-- 如果没有该配置,devtools不会生效: 打包时关闭 -->
+                    <fork>false</fork>
+                    <!-- 避免中文乱码 -->
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <!-- 允许生成可运行jar: 发布作为基础包提供, 注释 executions 再执行 install 到本地 maven. 若开启即可作为独立 jar 运行 -->
+                <executions>
+                    <execution>
+                        <goals>
+                            <!--                            <goal>repackage</goal>-->
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 29 - 0
mjava/src/main/java/com/malk/Boot.java

@@ -0,0 +1,29 @@
+package com.malk;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+import javax.persistence.EntityManager;
+
+@EnableJpaAuditing
+@SpringBootApplication
+public class Boot {
+
+    public static void main(String... args) {
+        SpringApplication.run(Boot.class, args);
+    }
+
+    /**
+     * 让Spring管理JPAQueryFactory [多数据源配置详见DataSourceConfig]
+     *
+     * @Qualifier("entityManagerFactory") 单数据源指向
+     * @Qualifier("entityManagerFactoryPrimary") 多数据源指向
+     */
+    @Bean
+    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
+        return new JPAQueryFactory(entityManager);
+    }
+}

+ 29 - 0
mjava/src/main/java/com/malk/base/BaseDao.java

@@ -0,0 +1,29 @@
+package com.malk.base;
+
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/**
+ * @EntityListeners 且需要在启动类添加 @EnableJpaAuditing 注解, 即可在字段上添加 @CreatedDate & @LastModifiedDate 实现 [Date 类型数据 - 自动插入创建 & 更新时间]
+ * -
+ * 项目绑定数据库后,进行分配。即可消除@Table、 在 idea 下不能匹配到信息的报错提示
+ * @Table指定表【下划线和小驼峰都可】, 若使用驼峰则依然会有报错提示. 若类名从驼峰转为下划线和表名相同则可不需要注解
+ * -
+ * 自定义查询
+ * @Query 当设置nativeQuery=true即可以使用原生SQL进行查询, 默认为false, 使用JPQL语法 [JPQL不支持insert语法, 注意: nativeQuery查询使用表名, 不能使用对象名]
+ * - 索引参数: 索引值从1开始,查询中"?X"个数需要与方法定义的参数个数相一致,并且顺序也要一致
+ * - 命名参数: 可以定义好参数名,赋值时使用@Param("参数名"), 而不用管顺序. 对入参使用 @Param("email") 修饰, @Query内使用 :email 进行取值
+ * - 关于返回值: update 语法, 返回值为 void. select 语法, 返回单个可用集合接收; 若返回多个用对象接收就会异常
+ * @Modifying注解 编写JPQL实现DELETE和UPDATE操作的时候必须加上@modifying注解,以通知Spring Data 这是一个DELETE或UPDATE操作
+ * -
+ * 事务管理 @Transactional注解,可以修饰类或方法
+ * - 配置 jpa.database-platform: org.hibernate.dialect.MySQL57Dialect, JPA建表的默认引擎修改为:InNoDB
+ * - 在启动类添加 @EnableTransactionManagement 注解, 开启事务支持后,然后在访问数据库的Service方法上添加注解 @Transactional 即可 [Service指dao, 或调用dao的地方]
+ * -
+ * 动态匹配, 注解于类
+ * @DynamicInsert : 默认true,指定用于INSERT的 SQL 将会在运行时动态生成,并且只包含那些非空值字段。(如果是)
+ * @DynamicUpdate : 默认true, 指定用于UPDATE 的SQL将会在运行时动态生成,并且只更新那些改变过的字段
+ */
+public interface BaseDao extends JpaRepository<BasePo, Long> {
+
+}

+ 111 - 0
mjava/src/main/java/com/malk/base/BaseDto.java

@@ -0,0 +1,111 @@
+package com.malk.base;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.BeanWrapperImpl;
+
+import java.beans.PropertyDescriptor;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 基础对象
+ *
+ * @JsonInclude(JsonInclude.Include.NON_NULL):类注解过滤null字段,包含入参和返回值【参数类搭配 @Data,才可以实例化返回值以及ToString输出】
+ * -
+ * lombok
+ * @Data : 注在类上,提供类的get、set、equals、hashCode、canEqual、toString方法
+ * @AllArgsConstructor : 注在类上,提供类的全参构造
+ * @NoArgsConstructor : 注在类上,提供类的无参构造
+ * @Setter : 注在属性上,提供 set 方法
+ * @Getter : 注在属性上,提供 getDefault 方法
+ * @EqualsAndHashCode : 注在类上,提供对应的 equals 和 hashCode 方法
+ * @Log4j/@Slf4j : 注在类上,提供对应的 Logger 对象,变量名为 log
+ * @Builder:为类生成相对略微复杂的构建器API。来初始化实例对象::类名.属性(值).属性(值).build()
+ * @Singular:在使用@Singular注释注释一个集合字段(使用@Builder注释类),lombok会将该构建器节点视为一个集合,并生成两个adder方法而不是setter方法::点一次集合增加一个元素
+ * @Builder.Default:在类中id和insertTime上都添加注解@Builder.Default,当在使用这个实体对象时,就不需要在为这两个字段进行初始化值
+ */
+@Data
+@NoArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public abstract class BaseDto {
+
+    /**
+     * 对象拷贝: 若是复制一个对象, 建议使用 cloneParam 避免性能问题
+     */
+    @Deprecated
+    @SneakyThrows
+    public BaseDto copyParam() {
+        BaseDto dto = this.getClass().newInstance();
+        BeanUtils.copyProperties(this, dto);
+        return dto;
+    }
+
+    /**
+     * * todo: 4.11 继承Serializable后执行深拷贝, 不能类型转换, 不继承Serializable则返回
+     * 对象拷贝: 复制一个新的对象, 避免条件被修改, 尤其并发下分页混乱情况
+     */
+    public BaseDto cloneParam() {
+        return ObjectUtil.clone(this);
+    }
+
+    /**
+     * 对象属性合并: jda之save接口会以传入数据为准,若传入为空或不传入,更新会置空。目前解决办法两种,通过注解实现JPQL/SQL,或者查询出数据,将未传入字段属性拷贝后再更新【性能消耗】
+     */
+    public void mergeParam(BaseDto modifyDo) {
+        BeanUtils.copyProperties(this, modifyDo, getNotNullPropertyNames(modifyDo));
+    }
+
+    // 忽略有值的字段
+    private static String[] getNotNullPropertyNames(Object target) {
+        final BeanWrapper src = new BeanWrapperImpl(target);
+        PropertyDescriptor[] pds = src.getPropertyDescriptors();
+        Set<String> emptyNames = new HashSet();
+        for (PropertyDescriptor pd : pds) {
+            Object srcValue = src.getPropertyValue(pd.getName());
+            if (srcValue != null) {
+                // 此处判断可根据需求修改, 目前过滤不为null
+                emptyNames.add(pd.getName());
+            }
+        }
+        String[] result = new String[emptyNames.size()];
+        return emptyNames.toArray(result);
+    }
+
+    /**
+     * 传入映射的Map, 将实体属性和Map的key转换, 返回Map
+     */
+    public Map convertEntity(Map<String, String> reflect) {
+        Map map = JSON.parseObject(JSON.toJSONString(this, SerializerFeature.WriteNullStringAsEmpty), Map.class);
+        Map<String, String> formData = new HashMap();
+        for (String key : reflect.keySet()) {
+            String content = String.valueOf(map.get(key));
+            // json序列化已经将空字符串过滤, 若转换还有null字符串, 可能是key为null或SerializerFeature未指定到类型, 如Date
+            if (StringUtils.isNotBlank(content) && !content.equals("null")) formData.put(reflect.get(key), content);
+        }
+        return formData;
+    }
+
+    /**
+     * Map时间格式化, 直接从数据库取值后Map会有市区差, 方法1见BasePo, @Temporal & @JsonFormat 注解
+     * -
+     * [单独时间格式化 [废弃]]
+     * * JSON.parseArray(JSON.toJSONString(data), Map.class).stream().map(item -> {
+     * *     item.put("tStoreInTime", UtilDateTime.formatDateTime(new Date(UtilMap.getLong(item, "tStoreInTime"))));
+     * *     return item;
+     * * });
+     */
+    public static final Object jsonFormatDateTime(Object data) {
+        return JSON.parse(JSON.toJSONString(data, SerializerFeature.WriteNullStringAsEmpty, SerializerFeature.DisableCircularReferenceDetect, SerializerFeature.WriteDateUseDateFormat));
+    }
+}

+ 64 - 0
mjava/src/main/java/com/malk/base/BasePo.java

@@ -0,0 +1,64 @@
+package com.malk.base;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.excel.annotation.ExcelIgnore;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import javax.persistence.*;
+import java.util.Date;
+
+/**
+ * 实体基类
+ *
+ * @MappedSuperclass 作为公共属性场景. 类注解, 标注后该类将不是一个完整的实体类,也不会映射到数据库表,但其的属性都将映射到其子类的数据库字段中
+ * @Cloumn java为小驼峰命名,数据库为下划线命名,若有差异,通过name指定表字段. 若相同则可不用指定name [直接设置字段值即可,无效额外注解]
+ * -
+ * 功能使用自带
+ * -
+ * @JsonFormat(pattern = "yyyy-MM-dd HH:smm:ss", timezone = "GMT+8"),数据库 Date 类型序列化后转到前台的指定格式【jackjson】
+ * @DateTimeFormat @DateTimeFormat(pattern = "yyyy-MM-dd"),使用和@jsonFormat差不多,前台传入的按照指定格式自动转为Date储存
+ * @JsonIgnoreProperties 类注解,作用是json序列化时将bean中的一些属性忽略掉,序列化和反序列化都受影响。支持多个属性 [也用于双向绑定解决循环序列化]
+ * @JsonIgnore 此注解用于属性或者方法上(最好是属性上),作用和上面的@JsonIgnoreProperties一样,屏蔽该字段在序列化和数据发挥会自动忽略
+ * @JsonGetter 用于序列化, 还可指定返回属性名,@JsonSetter 用于反序列化。注意@JsonGetter比@JsonProperty的优先级高,同时存在属性忽略会失效
+ * @Transient ORM框架将忽略该属性,不入库。如果一个属性并非数据库表的字段映射,就务必将其标示为@Transient,否则ORM框架默认其注解为@Basic
+ * @Temporal & @JsonFormat: fixme: 指定时区, new Date 会默认当前系统时区, 不添加 json 时区注解, 会出现序列化后的对象时间不是 GMT 时区 [方法2见BaseDto, jsonFormatDateTime]
+ */
+@MappedSuperclass
+@Data
+@NoArgsConstructor
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BasePo extends BaseDto {
+
+    // 若是实体若不直接在 com.malk 下, 可声明继承id, 避免编辑器提示 [不加也不影响编译以及运行] [ppExt: 现有表如u8, 不继承 BaseDto, 避免默认id与时间字段匹配异常]
+    @ExcelIgnore
+    @Id
+    @JsonIgnore
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    public Long id;
+
+    @ExcelIgnore
+    @CreatedDate
+    @Temporal(TemporalType.TIMESTAMP)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createTime;
+
+    @ExcelIgnore
+    @LastModifiedDate
+    @Temporal(TemporalType.TIMESTAMP)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updateTime;
+
+    public void upsert(BasePo po_old) {
+
+        if (ObjectUtil.isNotNull(po_old)) {
+            this.id = po_old.id;
+            this.setCreateTime(po_old.getCreateTime());
+        }
+    }
+}

+ 9 - 0
mjava/src/main/java/com/malk/base/BaseRepository.java

@@ -0,0 +1,9 @@
+package com.malk.base;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.querydsl.QuerydslPredicateExecutor;
+
+public interface BaseRepository extends JpaRepository<BasePo, Long>, QuerydslPredicateExecutor<BasePo> {
+
+}
+

+ 78 - 0
mjava/src/main/java/com/malk/base/JpaMap.java

@@ -0,0 +1,78 @@
+package com.malk.base;
+
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.data.jpa.domain.Specification;
+
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 查询map [不指定表名]
+ * 通过Map实现, 无需定义实体, 主键设置Long即可: public interface HSViewDao extends CrudRepository<JpaMap, Long>
+ * -
+ * 使用native查询,返回Map. 直接查询表名 + 列名 + 条件, 可不做实体映射场景
+ * 时间的时区处理: 方法1见BasePo, @Temporal & @JsonFormat 注解; 方法2见BaseDto, jsonFormatDateTime
+ * 空条件jpa实现, 查询全部数据, 通过 like LTRIM('%' + ?x + '%') 进行实现 [dsl适用于复杂场景, Specification谓词需要依赖于实体]
+ * -
+ * 多条件查询
+ * 1. sql + if [SqlServer不支持if条件]
+ * * @Query(value = "select * from vwpbCommonDataOrderPlan where if (?1 is not null, dPlanDate >= ?1, 1=1) and if (?2 is not null, dPlanDate <= ?2, 1=1) and sOrderNo like LTRIM('%' + ?3 + '%')", nativeQuery = true)
+ * * Page<Map> queryOrderPlan(Date start, Date end, String sOrderNo, Pageable pageable);
+ * 2. Specification, 谓词需要依赖于实体 [参考示例, 如下 test 方法实现]
+ * - 说明
+ * 1. @Query: nativeQuery查询使用表名, 不能使用对象名
+ * 2. ppExt: 现有表如u8, 不继承 BaseDto, 避免默认id与时间字段匹配异常
+ */
+
+@Entity
+public class JpaMap {
+
+    @Id
+    private String id;
+
+    /**
+     * 时间的时区处理
+     */
+    public static final List<Map> jsonFormatDateTime(List<Map> data) {
+        return JSON.parseArray(JSON.toJSONString(data, SerializerFeature.WriteNullStringAsEmpty, SerializerFeature.DisableCircularReferenceDetect, SerializerFeature.WriteDateUseDateFormat), Map.class);
+    }
+
+    // 示例: Specification, 谓词需要依赖于实体
+    private void test() {
+        //Page<Map> findAll (Specification < T > spec, Pageable pageable);
+
+        String sOrderNo = "";
+        Date sTime = null;
+        Date eTime = null;
+
+        Specification spec = (root, criteriaQuery, criteriaBuilder) -> {
+            List<javax.persistence.criteria.Predicate> predicates = new ArrayList<>();
+            if (StringUtils.isNotBlank(sOrderNo)) {
+                javax.persistence.criteria.Predicate predicate = criteriaBuilder.like(root.get("sOrderNo"), "%" + sOrderNo + "%");
+                predicates.add(predicate);
+            }
+
+            if (ObjectUtil.isNotNull(sTime)) {
+//            javax.persistence.criteria.Predicate predicate = criteriaBuilder.greaterThanOrEqualTo(root.getDefault("dPlanDate").as(String.class), "2023-06-03 00:00:00");
+                javax.persistence.criteria.Predicate predicate = criteriaBuilder.greaterThanOrEqualTo(root.get("dPlanDate"), sTime);
+                predicates.add(predicate);
+            }
+
+            if (ObjectUtil.isNotNull(eTime)) {
+                javax.persistence.criteria.Predicate predicate = criteriaBuilder.lessThanOrEqualTo(root.get("dPlanDate"), eTime);
+                predicates.add(predicate);
+            }
+            return criteriaBuilder.and(predicates.toArray(new javax.persistence.criteria.Predicate[predicates.size()]));
+            /// 或
+            ///return criteriaQuery.where(predicates.toArray(new javax.persistence.criteria.Predicate[predicates.size()])).getRestriction();
+        };
+    }
+}

+ 35 - 0
mjava/src/main/java/com/malk/config/JpaConfiguration.java

@@ -0,0 +1,35 @@
+package com.malk.config;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+
+/**
+ * JPA [错误抛出与拦截详见CatchException, 多数据源配置参考DataSourceConfig]
+ * -
+ * 1. 关键字语法: save find, exists, count [单表操作 - 若数据库列和对象属性字段一致, 直接使用 jpa 自带查询匹配不到启动报错]
+ * 2. 自定义查询: @Query, 通过 nativeQuery 区分 jpql 和 sql 语法 [简单查询]
+ * 3. 表关联关系: @OneToMany, @ManyToOne, @ManyToMany, @OneToOne [耦合关联]
+ * 4. Specification: Predicate 与 CriteriaBuilder 组合 [谓词, 需要依赖于实体]
+ * 5. QueryDSL: 基于ORM框架以及SQL之上的一个通用的查询框架, 分页, 关联查询原生支持 [查询框架] (通过查询关联, 而不是如 @OneToMany 等建立表关联)
+ * 6. DSL: 项目在 compile 会执行 apt-maven-plugin 插件, 将 @Entity 注解类, 添加 Q 前缀, 存放到 target 下 generated-source 目录
+ * 7. 配置: 扫描基础路径, 涉及子项目也能注册到, 避免启动报错. 子项目 Boot 配置 @SpringBootApplication(scanBasePackages = {"com.mcli"})
+ * 8. 查询: 使用native,查询列不匹配实体属性,会报错The column name xxx is not valid,返回Map可解决 (Map是Jpa的TupleBackedMap, 通过try取值)
+ * 9. 单数据源切换: 在dao与entity均添加mutual作为公共模块, 单数据源下服务于JpaConfiguration, 若是多数据源与PrimaryConfig一起作为主数据源配置
+ * 10 主子项目, 在单数据源情况下, 扫描全部. [需要注意的是, 在多数据源下同名dao是可以通过指定数据源使用, 此时若开启单数据源启动报错, 会扫描全部]
+ */
+@ConditionalOnProperty(name = "spel.multiSource", havingValue = "false")
+@Configuration
+// 单数据源, 扫描子项目与主项目primary [子项目可以访问到主项目primary]
+@EnableJpaRepositories(basePackages = {"com.malk.*.repository.dao", "com.malk.repository.dao.primary"})
+@EntityScan(basePackages = {"com.malk.*.repository.entity", "com.malk.repository.entity.primary"})
+public class JpaConfiguration {
+
+    @Bean
+    PersistenceExceptionTranslationPostProcessor persistenceExceptionTranslationPostProcessor() {
+        return new PersistenceExceptionTranslationPostProcessor();
+    }
+}

+ 29 - 0
mjava/src/main/java/com/malk/config/OpenApiConfig.java

@@ -0,0 +1,29 @@
+package com.malk.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * OpenAPI (Swagger UI) 配置
+ * -
+ * 仅当 application.yml 中 swagger.enable=true 时加载;
+ * 配合 springdoc.api-docs.enabled / springdoc.swagger-ui.enabled 共同控制。
+ * -
+ * UI 路径:{contextPath}/swagger-ui.html
+ * JSON 路径:{contextPath}/v3/api-docs
+ */
+@Configuration
+@ConditionalOnProperty(name = "swagger.enable", havingValue = "true")
+public class OpenApiConfig {
+
+    @Bean
+    public OpenAPI mjavaOpenAPI() {
+        return new OpenAPI().info(new Info()
+                .title("mjava API")
+                .description("mjava 基座对外 HTTP 接口文档")
+                .version("v1"));
+    }
+}

+ 58 - 0
mjava/src/main/java/com/malk/config/WebConfiguration.java

@@ -0,0 +1,58 @@
+package com.malk.config;
+
+import com.malk.filter.RequestInterceptor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebConfiguration implements WebMvcConfigurer {
+
+    // 指定类输出日志到指定文件夹
+    private static final Logger logger = LoggerFactory.getLogger("point");
+
+    // 请求拦截
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        logger.info("拦截器 ▷ 初始化");
+        registry.addInterceptor(new RequestInterceptor())
+                .addPathPatterns("/**")
+                // ppExt: 若无需对外访问, 不用添加路径. static/public 为默认
+                .excludePathPatterns("/assets/**", "/templates/**");
+    }
+
+    // 跨域支持: 端口不匹配也会报跨域, 若是单个控制器开放, 可使用 @CrossOrigin 注解
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        logger.info("拦截器 ▷ 开启CORS");
+        registry.addMapping("/**")  // 添加映射路径
+                .allowedOrigins("*")  // 放行哪些原始
+                .allowCredentials(true)  // 是否发送Cookie信息
+                .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")  // 放行哪些原始域(请求方式)
+                .allowedHeaders("*")  // 放行哪些原始域(头部信息)
+                .allowCredentials(true);  // 放行证书
+    }
+
+    /**
+     * 静态资源映射
+     * -
+     * 默认的静态资源路径为: classpath:/META-INF/resources/, classpath:/resources/,classpath:/static/, classpath:/public [默认路径不会进拦截器]
+     * 读取的是target内容, 访问路径 assets: http://localhost:9001/api/assets/logo/logo-text.png [自定义拦截器添加路径排除: excludePathPatterns]
+     * web网页路径: http://localhost:9011/api/项目路径/web2/index.html#/模块名称/home [前后端模块化, 不配置Nginx从Tomcat透出vue页面]
+     * 当在SpringBoot项目内添加网页资源时,在windows服务器,需要C:\Windows\System32下添加tomcat-native-1.2.14-win32-bin.zip内x64下两个文件, 重启项目
+     * -
+     * ppExt: ClassPathResource, 需要打包/编译后才能访问到. 识别不是架包内内容 [static 可自动过滤, 自动识别子文件夹作为 path]
+     * [两个示例]: mjs http://localhost:9001/api/项目路径/mjs/mjs.min.js / http://localhost:9001/api/项目路径/json/personnel.json
+     */
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+        registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
+        registry.addResourceHandler("/web2/**").addResourceLocations("classpath:/static/web2/");
+        registry.addResourceHandler("/assets/**").addResourceLocations("classpath:/assets/");
+        registry.addResourceHandler("/templates/**").addResourceLocations("classpath:/templates/");
+    }
+}

+ 41 - 0
mjava/src/main/java/com/malk/config/mutilSource/DataSourceConfig.java

@@ -0,0 +1,41 @@
+package com.malk.config.mutilSource;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.jdbc.DataSourceBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+
+import javax.sql.DataSource;
+
+/**
+ * 数据源配置
+ * -
+ * AOP多数据源切换:
+ * - 1. pom文件添加spring-boot-starter-jdbc依赖
+ * - 2. yml配置多主从数据库, 数据库地址从url修改为jdbc-url
+ * - 3. 通过spel.multiSource控制是否多数据源, 屏蔽JpaConfiguration, 加载DataSourceConfig
+ * 不同数据源存在同名表:
+ * - 1. 实体只需要在dao层区分即可, 作用域下实体包路径是不同的
+ * - 2. 同名的dao通过@Repository("slaveMcTableDao")定义区分; 在Autowired引用时候属性上添加@Qualifier("slaveMcTableDao")注解
+ * -
+ * 单数据源切换: 在dao与entity均添加mutual作为公共模块, 单数据源下服务于JpaConfiguration, 若是多数据源与PrimaryConfig一起作为主数据源配置
+ */
+@ConditionalOnProperty(name = "spel.multiSource", havingValue = "true")
+@Configuration
+public class DataSourceConfig {
+
+    @Bean(name = "primaryDataSource")
+    @ConfigurationProperties(prefix = "spring.datasource.primary")
+    @Primary
+    public DataSource primaryDataSource() {
+        return DataSourceBuilder.create().build();
+    }
+
+    @Bean(name = "slaveDataSource")
+    @ConfigurationProperties(prefix = "spring.datasource.slave")
+    public DataSource slaveDataSource() {
+        return DataSourceBuilder.create().build();
+    }
+}

+ 80 - 0
mjava/src/main/java/com/malk/config/mutilSource/PrimaryConfig.java

@@ -0,0 +1,80 @@
+package com.malk.config.mutilSource;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings;
+import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
+import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.orm.jpa.JpaTransactionManager;
+import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import javax.persistence.EntityManager;
+import javax.sql.DataSource;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 主数据源配置,多数据源必须设置一个主数据源, 通过 @Primary 注解 [包含公共部分]
+ * -
+ * 主子项目, 在多数据源情况下, 子项目需要匹配主项目目录结构, 且符合命名规范. 配置对应扫描 EnableJpaRepositories / EntityScan 添加子项目路径
+ */
+@ConditionalOnProperty(name = "spel.multiSource", havingValue = "true")
+@Configuration
+@EnableTransactionManagement
+@EnableJpaRepositories(
+        entityManagerFactoryRef = "entityManagerFactoryPrimary", // 配置连接工厂 entityManagerFactory
+        transactionManagerRef = "transactionManagerPrimary", // 配置事物管理器  transactionManager
+        basePackages = {"com.malk.repository.dao.primary", "com.malk.*.repository.dao.primary", "com.malk.base"}  // dao层配置主数据 & 公共所在目录 [子项目可以访问到主项目primary]
+)
+public class PrimaryConfig {
+
+    @Autowired
+    @Qualifier("primaryDataSource")  // 指定这是主数据源,为了和从(其他)数据源区别开,因为@Autowired不能导入名称相同的是bean
+    private DataSource dataSourcePrimary;
+
+    @Autowired
+    private JpaProperties jpaProperties;
+
+    @Autowired
+    private HibernateProperties hibernateProperties;
+
+    @Primary
+    @Bean("entityManagerPrimary")
+    public EntityManager entityManager(EntityManagerFactoryBuilder builder) {
+        return Objects.requireNonNull(entityManagerFactoryBean(builder).getObject()).createEntityManager();
+    }
+
+    @Primary
+    @Bean("entityManagerFactoryPrimary")
+    public LocalContainerEntityManagerFactoryBean entityManagerFactoryBean(EntityManagerFactoryBuilder builder) {
+        return builder.dataSource(dataSourcePrimary)
+                .properties(getVendorProperties())
+                // 设置实体类所在目录: 包含主数据源与公共 [子项目可以访问到主项目primary]
+                .packages("com.malk.repository.entity.primary", "com.malk.*.repository.entity.primary", "com.zhuogao.base")
+                // 持久化单元名称,当存在多个EntityManagerFactory时,需要制定此名称
+                .persistenceUnit("primaryPersistenceUnit")
+                .build();
+    }
+
+    private Map<String, Object> getVendorProperties() {
+        return hibernateProperties.determineHibernateProperties(
+                jpaProperties.getProperties(),
+                new HibernateSettings()
+        );
+    }
+
+    @Primary
+    @Bean("transactionManagerPrimary")
+    public PlatformTransactionManager transactionManager(EntityManagerFactoryBuilder builder) {
+        return new JpaTransactionManager(Objects.requireNonNull(entityManagerFactoryBean(builder).getObject()));
+    }
+}
+

+ 76 - 0
mjava/src/main/java/com/malk/config/mutilSource/SlaveConfig.java

@@ -0,0 +1,76 @@
+package com.malk.config.mutilSource;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings;
+import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
+import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.orm.jpa.JpaTransactionManager;
+import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import javax.persistence.EntityManager;
+import javax.sql.DataSource;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 从数据源配置
+ * -
+ * 主子项目, 在多数据源情况下, 子项目需要匹配主项目目录结构, 且符合命名规范. 配置对应扫描 EnableJpaRepositories / EntityScan 添加子项目路径
+ */
+@ConditionalOnProperty(name = "spel.multiSource", havingValue = "true")
+@Configuration
+@EnableTransactionManagement
+@EnableJpaRepositories(
+        entityManagerFactoryRef = "entityManagerFactorySlave",
+        transactionManagerRef = "transactionManagerSlave",
+        basePackages = {"com.malk.repository.dao.slave", "com.malk.*.repository.dao.slave"} // slave无公共类, 需单独添加 [子项目可以访问到主项目primary]
+)
+public class SlaveConfig {
+
+    @Autowired
+    @Qualifier("slaveDataSource")
+    private DataSource dataSourceSlave;
+
+    @Autowired
+    private JpaProperties jpaProperties;
+
+    @Autowired
+    private HibernateProperties hibernateProperties;
+
+    @Bean("entityManagerSlave")
+    public EntityManager entityManager(EntityManagerFactoryBuilder builder) {
+        return Objects.requireNonNull(localContainerEntityManagerFactoryBean(builder).getObject()).createEntityManager();
+    }
+
+    @Bean("entityManagerFactorySlave")
+    public LocalContainerEntityManagerFactoryBean localContainerEntityManagerFactoryBean(EntityManagerFactoryBuilder builder) {
+        return builder.dataSource(dataSourceSlave)
+                .properties(getVendorProperties())
+                // 设置实体类所在目录 [slave无公共类, 需单独添加, 如 JpaMapSlave]
+                .packages("com.malk.repository.entity.slave", "com.malk.*.repository.entity.slave")
+                // 持久化单元名称,当存在多个EntityManagerFactory时,需要制定此名称
+                .persistenceUnit("slavePersistenceUnit")
+                .build();
+    }
+
+    private Map<String, Object> getVendorProperties() {
+        return hibernateProperties.determineHibernateProperties(
+                jpaProperties.getProperties(),
+                new HibernateSettings()
+        );
+    }
+
+    @Bean("transactionManagerSlave")
+    public PlatformTransactionManager transactionManager(EntityManagerFactoryBuilder builder) {
+        return new JpaTransactionManager(Objects.requireNonNull(localContainerEntityManagerFactoryBean(builder).getObject()));
+    }
+}
+

+ 80 - 0
mjava/src/main/java/com/malk/controller/DDCallbackController.java

@@ -0,0 +1,80 @@
+package com.malk.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.common.McR;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.crypto.DingCallbackCrypto;
+import com.malk.service.dingtalk.DDClient_Event;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Arrays;
+import java.util.Map;
+
+/**
+ * 钉钉事件回调 3_1
+ * -
+ * [子项目直接继承即可有调用, 无需实现]
+ * -
+ * 注解 @RequestMapping 路径不能重复 [主子项目属同一个项目];
+ * 获取项目回调请求地址, https://mc.cloudpure.cn/frp/mc/dd/callback [调试代理: frp + nginx]
+ */
+@Slf4j
+@RestController
+@RequestMapping("/mc/dd")
+public class DDCallbackController {
+
+    @Autowired
+    private DDConf ddConf;
+
+    @Autowired
+    private DDClient_Event ddClient_event;
+
+    /**
+     * 钉钉审批回调: [依赖包方案已弃用]
+     * -
+     * DingCallbackCrypto 方案: 官网案例 DingCallbackCrypto 不在钉钉架包, 需要单独引用
+     * 在钉钉开放平台重新保存回调地址后, 所有的注册事件会被关闭:: 通过代码注册不成功; 官方回复, 要么使用调用注册的方式 要么是后台的方式, 二选一
+     */
+    @SneakyThrows
+    @RequestMapping(value = "/callback", method = RequestMethod.POST)
+    public Map<String, String> invokeCallback(@RequestParam(value = "signature", required = false) String signature,
+                                              @RequestParam(value = "timestamp", required = false) String timestamp,
+                                              @RequestParam(value = "nonce", required = false) String nonce,
+                                              @RequestBody(required = false) JSONObject json) {
+
+        DingCallbackCrypto callbackCrypto = new DingCallbackCrypto(ddConf.getToken(), ddConf.getAesKey(), ddConf.getAppKey());
+        final String decryptMsg = callbackCrypto.getDecryptMsg(signature, timestamp, nonce, json.getString("encrypt"));
+        JSONObject eventJson = JSON.parseObject(decryptMsg);
+        Map success = callbackCrypto.getEncryptedMap(DDConf.CALLBACK_RESPONSE, System.currentTimeMillis(), DingCallbackCrypto.Utils.getRandomStr(8));
+        String eventType = eventJson.getString("EventType");
+        if (DDConf.CALLBACK_CHECK.equals(eventType)) {
+            log.info("----- [DD]验证注册 -----");
+            return success;
+        }
+        // [回调任务执行逻辑: 异步] 钉钉超时3s未返回会被记录为失败, 可通过失败接口获取记录
+        if (Arrays.asList(DDConf.BPMS_INSTANCE_CHANGE, DDConf.BPMS_TASK_CHANGE).contains(eventType)) {
+            log.info("[DD]审批回调, {}", eventJson);
+            ddClient_event.callBackEvent_Workflow(eventJson);
+            return success;
+        }
+
+        if (Arrays.asList(DDConf.ATTENDANCE_CHECK_RECORD).contains(eventType)) {
+            log.info("[DD]考勤回调, {}", eventJson);
+            ddClient_event.callBackEvent_Attendance(eventJson);
+            return success;
+        }
+
+        log.info("----- [DD]已注册, 未处理的其它回调 -----, {}", eventJson);
+        return success;
+    }
+
+    @PostMapping("robot")
+    McR robot(@RequestBody Map data) {
+        log.info("xxx, {}", data);
+        return McR.success();
+    }
+}

+ 61 - 0
mjava/src/main/java/com/malk/controller/TBCallBackController.java

@@ -0,0 +1,61 @@
+package com.malk.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.malk.delegate.TBEvent;
+import com.malk.server.teambition.TBConf;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * TB事件回调 3_1
+ * 1. 回调可选择加密与不加密方式, tb发送成功为上游, 注册服务为下游
+ * 2. 绑定回调需要安装后才会生效, 且回调范围更新后需要重新安装才会生效 [另外接口调用修改配置需要重新发布后生效]
+ */
+@Slf4j
+@RestController
+@RequestMapping("/mc/tb")
+public class TBCallBackController {
+
+    @Autowired
+    private TBEvent tbEvent;
+
+    /**
+     * * 回调说明 [ppExt: 字段更新回调, 判定字段ID, 避免循坏触发问题, 接口修改也会触发webhook]
+     * * 1. 通过接口更操作的数据,也会与手动创建一样触发相同的回调, 除了项目更新接口调用实测不会触发回调, 手动修改正常回调
+     * * 2. 项目创建会推送两次
+     * * - 1. 在第二次推送多 { data: { project: { operatorId, url }} } 这两个字段内容
+     * * - 2. 若是通过模板创建的项目,在两次项目更新回调中会回调一次 project.enable 回调, 其中任务只会回调创建, 不会回调更新
+     * * 3. 任务创建, 会先回调创建事件, 接着立即回调任务更新事件 [若是通过模板创建, 任务只会回调创建, 不会回调更新]
+     * * 4. 项目移入回收站,不会触发回调,删除后会触发项目与任务的 remove 事件; 若是将任务移入回收站, 会触发任务更新回调
+     */
+    @PostMapping("callback")
+    public String callback(@RequestBody JSONObject eventJson) {
+
+        String success = "success";
+        String eventName = eventJson.getString("event");
+
+        if (TBConf.EVENT_VERIFY_HOOK.equals(eventName)) {
+            log.info("----- [TB]验证注册 -----");
+            return success;
+        }
+
+        if (eventName.contains("task")) {
+            log.info("[TB]任务回调, {}, {}", eventName, eventJson);
+            tbEvent.callBackTask(eventJson);
+            return success;
+        }
+
+        if (eventName.contains("project")) {
+            log.info("[TB]项目回调, {}, {}", eventName, eventJson);
+            tbEvent.callBackProject(eventJson);
+            return success;
+        }
+
+        log.info("----- [TB]已注册, 未处理的其它回调 -----, {}, {}", eventName, eventJson);
+        return success;
+    }
+}

+ 103 - 0
mjava/src/main/java/com/malk/core/AsyncConfig.java

@@ -0,0 +1,103 @@
+package com.malk.core;
+
+//import com.mcli.filter.ExceptionNotice;
+
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.AsyncConfigurer;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * 线程池配置 [函数式编程:详见YDClient/YDService使用说明]
+ * -
+ * 前提: 1. 必须仅适用于 public 实例方法; 2.在同一个类中调用异步方法将无法正常工作(self-invocation); 3. 访问对象必须通过 @Autowired 进行注入, 否则无效
+ * 返回: 1. 一般为 void, 若需要返回值, 使用Future. 2. 不建议使用回调函数: 回调函数没有状态, 若执行过程中出现数据增删, 简单的通过数量判断不可取 [YDService]
+ * 备注: 开启异步在配置类添加 @EnableAsync 类注解, 无需在启动类添加 [非必要]
+ * 扩展:
+ * - 1. 可以搭配 lombok 的 @Synchronized, 实现异步情况下的线程安全. 会等到前一次执行成功后执行后一次线程调用 [若是不使用 @Async, 可能会超时失败, 宜搭不稳定]
+ * - 2. 指定线程 @Async("aliworkConcurrence") 配置, 统一配置调用上限避免超并发 [宜搭, YDClient内并发公共一个@Async配置]
+ */
+@Slf4j
+@Configuration
+@EnableAsync
+public class AsyncConfig implements AsyncConfigurer {
+
+    // 指定类输出日志到指定文件夹
+    private static final Logger logger = LoggerFactory.getLogger("point");
+
+//    @Autowired
+//    private ExceptionNotice notice;
+
+    /**
+     * 异步执行配置
+     *
+     * @async 更建议是为了实现异步, 若有并发等待也可满足
+     * Delayed 函数回调存在线程死锁, 导致内存问题, 已弃用
+     */
+    @Override
+    public Executor getAsyncExecutor() {
+        logger.info("asyncExecutor ▷ 多线程执行");
+        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();  // 定义线程池
+        taskExecutor.setCorePoolSize(3);  // 设置核心线程
+        taskExecutor.setMaxPoolSize(10);  // 设置最大线程
+        taskExecutor.setQueueCapacity(200);  // 设置线程队列最大线程数
+        taskExecutor.setKeepAliveSeconds(10);  // 允许线程的空闲时间 [勿动]
+        taskExecutor.setThreadNamePrefix("async-executor-");  // 线程池名的前缀
+        taskExecutor.setTaskDecorator(new MdcTaskDecorator());  // 透传 traceId
+        taskExecutor.initialize();  // 初始化
+        return taskExecutor;
+    }
+
+    /**
+     * 并发线程配置: 宜搭 [统一配置调用上限避免超过并发, YDClient内并发公共一个@Async配置]
+     *
+     * @Async("aliworkConcurrence") 指定线程
+     */
+    @Bean("aliworkConcurrence")
+    public ThreadPoolTaskExecutor aliworkTaskExecutor() {
+        logger.info("aliworkConcurrence ▷ 多线程初始化");
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        // 核心线程数:线程池创建时候初始化的线程数 ==> 宜搭并发200次/s, 限流配置 [4核偶尔出现, 3核极其偶尔]
+        executor.setCorePoolSize(2);
+        // 最大线程数:线程池最大的线程数,只有在缓冲队列满了之后才会申请超过核心线程数的线程
+        executor.setMaxPoolSize(2);
+        // 缓冲队列:用来缓冲执行任务的队列
+        executor.setQueueCapacity(200);
+        // 允许线程的空闲时间60秒:当超过了核心线程之外的线程在空闲时间到达之后会被销毁
+        executor.setKeepAliveSeconds(60);
+        // 线程池名的前缀:设置好了之后可以方便我们定位处理任务所在的线程池
+        executor.setThreadNamePrefix("aliwork-concurrence-");
+        // 缓冲队列满了之后的拒绝策略:由调用线程处理(一般是主线程)
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
+        executor.setTaskDecorator(new MdcTaskDecorator());  // 透传 traceId
+        executor.initialize();
+        return executor;
+    }
+
+    /**
+     * 处理异步方法中未捕获的异常
+     * -
+     * ppExt 重要声明
+     * 1. @Async 内若调用了不能并发的方法, 如查询限流, 需要在调用的方法上添加 @Synchronized, 避免异步情况下执行触发限流导致抛出错误到多线程
+     * 2. @Async 方法上建议要添加 @Synchronized, 否则就在多次连续调用的时候, 入参需要做拷贝, 若同一个入参对象连续调用 @Async 会读取最后一次赋值
+     */
+    @Override
+    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
+        return (ex, method, params) -> {
+            log.error("Async反馈异常 {}. {}. {}", ex, method, params);  // 记录错误日志
+            try {
+                throw ex;
+            } catch (Throwable throwable) {
+                throwable.printStackTrace();
+            }
+        };
+    }
+}

+ 29 - 0
mjava/src/main/java/com/malk/core/MdcTaskDecorator.java

@@ -0,0 +1,29 @@
+package com.malk.core;
+
+import org.slf4j.MDC;
+import org.springframework.core.task.TaskDecorator;
+
+import java.util.Map;
+
+/**
+ * 把主线程的 MDC 拷贝到 @Async 子线程,避免 traceId 丢失。
+ * -
+ * 用法:在 ThreadPoolTaskExecutor 上 setTaskDecorator(new MdcTaskDecorator())
+ */
+public class MdcTaskDecorator implements TaskDecorator {
+
+    @Override
+    public Runnable decorate(Runnable runnable) {
+        Map<String, String> context = MDC.getCopyOfContextMap();
+        return () -> {
+            try {
+                if (context != null) {
+                    MDC.setContextMap(context);
+                }
+                runnable.run();
+            } finally {
+                MDC.clear();
+            }
+        };
+    }
+}

+ 42 - 0
mjava/src/main/java/com/malk/delegate/DDEvent.java

@@ -0,0 +1,42 @@
+package com.malk.delegate;
+
+import org.springframework.scheduling.annotation.Async;
+
+import java.util.Map;
+
+/**
+ * 钉钉事件回调 3_2
+ * -
+ * [主项目若无实现, 项目启动异常; 若子项目有订阅需添加 @Primary 以实现优先注入]
+ * -
+ * 子项目实现接口 [静态代理], 添加对应 processCode 单据业务逻辑
+ * OA审批, 撤销和拒绝流程不继续执行连接器, 通过事件订阅实现实时同步
+ */
+public interface DDEvent {
+
+
+    // todo, 回调做try, 失败记录做存储, 提供查询接口
+
+    // todo, 回调参数统一, 宜搭查询接口统一
+
+    // 审批任务回调执行业务逻辑
+    @Async
+    void executeEvent_Task_Finish(String processInstanceId, String processCode, boolean isAgree, String remark);
+
+    @Async
+    void executeEvent_Task_Start(String processInstanceId, String processCode);
+
+    @Async
+    void executeEvent_Task_Redirect(String processInstanceId, String processCode);
+
+    // 审批实例回调执行业务逻辑
+    @Async
+    void executeEvent_Instance_Finish(String processInstanceId, String processCode, boolean isAgree, boolean isTerminate, String staffId);
+
+    @Async
+    void executeEvent_Instance_Start(String processInstanceId, String processCode);
+
+    // 考勤打卡事件回调
+    @Async
+    void executeEvent_attendance_check(Map<String, ?> record);
+}

+ 17 - 0
mjava/src/main/java/com/malk/delegate/McDelegate.java

@@ -0,0 +1,17 @@
+package com.malk.delegate;
+
+import org.springframework.scheduling.annotation.Async;
+
+public interface McDelegate {
+
+    /**
+     * 异步线程回调
+     */
+    @Async
+    void setTimeout(Invoke fn, long millis);
+
+    @FunctionalInterface
+    interface Invoke {
+        void execute();
+    }
+}

+ 25 - 0
mjava/src/main/java/com/malk/delegate/TBEvent.java

@@ -0,0 +1,25 @@
+package com.malk.delegate;
+
+import com.alibaba.fastjson.JSONObject;
+
+/**
+ * TB事件回调 3_2
+ * -
+ * [主项目若无实现, 项目启动异常; 若子项目有订阅需添加 @Primary 以实现优先注入]
+ * -
+ * 子项目实现接口 [静态代理], 添加对应 processCode 单据业务逻辑
+ */
+public interface TBEvent {
+
+    /**
+     * 任务回调事件  [异步]
+     * ppExt
+     * 1. 若存在前后置, 未完成子任务, 点击完成TB会自动切换为未完成, 此种状态下不会触发任务回调
+     */
+    void callBackTask(JSONObject eventJson);
+
+    /**
+     * 项目回调事件  [异步]
+     */
+    void callBackProject(JSONObject eventJson);
+}

+ 57 - 0
mjava/src/main/java/com/malk/delegate/impl/DDImplEvent.java

@@ -0,0 +1,57 @@
+package com.malk.delegate.impl;
+
+import com.malk.delegate.DDEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+/**
+ * OA审批事件 [主项目若无实现, 项目启动异常; 若子项目有订阅需添加 @Primary 以实现优先注入]
+ * -
+ * 取消方案: 撤销和拒绝流程不继续执行连接器, 因此不使用连接器与轮询审批记录方案 [低效而且占用较高钉钉api调次数];
+ * 优化方案: 通过事件订阅实现实时同步所有审批状态, 定时查询钉钉回调失败记录 [配置钉钉事件Delegate, 添加定时任务]
+ */
+@Slf4j
+@Service
+public class DDImplEvent implements DDEvent {
+
+    // 审批任务回调执行业务逻辑
+    @Async
+    @Override
+    public void executeEvent_Task_Finish(String processInstanceId, String processCode, boolean isAgree, String remark) {
+        log.info("executeEvent_Task_Finish: 未被代理");
+    }
+
+    @Async
+    @Override
+    public void executeEvent_Task_Start(String processInstanceId, String processCode) {
+        log.info("executeEvent_Task_Start: 未被代理");
+    }
+
+    @Async
+    @Override
+    public void executeEvent_Task_Redirect(String processInstanceId, String processCode) {
+        log.info("executeEvent_Task_Redirect: 未被代理");
+    }
+
+    @Async
+    @Override
+    public void executeEvent_Instance_Start(String processInstanceId, String processCode) {
+        log.info("executeEvent_Instance_Start: 未被代理");
+    }
+
+    // 审批实例回调执行业务逻辑
+    @Async
+    @Override
+    public void executeEvent_Instance_Finish(String processInstanceId, String processCode, boolean isAgree, boolean isTerminate, String staffId) {
+        log.info("executeEvent_Instance_Finish: 未被代理");
+    }
+
+    // 考勤打卡事件回调
+    @Override
+    public void executeEvent_attendance_check(Map<String, ?> record) {
+        log.info("executeEvent_attendance_check: 未被代理");
+    }
+}

+ 21 - 0
mjava/src/main/java/com/malk/delegate/impl/McImplDelegate.java

@@ -0,0 +1,21 @@
+package com.malk.delegate.impl;
+
+import com.malk.delegate.McDelegate;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+@Service
+@Slf4j
+public class McImplDelegate implements McDelegate {
+
+    @SneakyThrows
+    @Async
+    @Override
+    public void setTimeout(Invoke fn, long millis) {
+
+        Thread.sleep(millis);
+        fn.execute();
+    }
+}

+ 29 - 0
mjava/src/main/java/com/malk/delegate/impl/TBImplEvent.java

@@ -0,0 +1,29 @@
+package com.malk.delegate.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.malk.delegate.TBEvent;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+public class TBImplEvent implements TBEvent {
+
+    @Async
+    @Override
+    @SneakyThrows
+    public void callBackTask(JSONObject eventJson) {
+        String eventName = eventJson.getString("event");
+        log.info("callBackTask: 未被代理");
+    }
+
+    @Async
+    @Override
+    @SneakyThrows
+    public void callBackProject(JSONObject eventJson) {
+        String eventName = eventJson.getString("event");
+        log.info("callBackProject: 未被代理");
+    }
+}

+ 215 - 0
mjava/src/main/java/com/malk/filter/CatchException.java

@@ -0,0 +1,215 @@
+package com.malk.filter;
+
+import com.alibaba.fastjson.JSONException;
+import com.malk.server.common.McException;
+import com.malk.server.common.McR;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.BindException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.HttpMediaTypeNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+
+import javax.validation.ConstraintDeclarationException;
+import javax.validation.ConstraintViolationException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 统一错误拦截
+ * -
+ * 参数校验 @Validated【javax.validation】
+ * 1. @Validated 针对实体类进行字段入参校验,可做分组,实现不同接口请求下字段的校验规则
+ * 2. @RestControllerAdvice 搭配 @ExceptionHandler 注解进行错误统一捕获和拦截返回,无需 try…catch 即可格式化返回标准输出::注意类名
+ * 3. 若无分组验证需求,可将@Validated 置于类上,不需要在@requestBody前声明。分组验证,可以在实体类组合好,若是取并集,则可直接在@requestBody上设置多个即可
+ * -
+ * 拦截param自定义message: @RequestParam默认是required,若想使用该检验,请求方法无需添加@RequestParam,且需要在Controller上添加@Validated
+ * 参数@RequestParam,可以配置默认值,指定name,指定name后可以设置是否必填。若是要获取所有(@RequestParam MultiValueMap<String, String> paramMap),每一个字段的值是一个集合
+ * -
+ * 关于在Service上使用校验, 首先需要impl类上添加@Validated [和Controller一致]
+ * 建议注解到方法声明上, 抛出到ConstraintViolationException. 若校验注解到方法实现上, 则错误抛出到 ConstraintDeclarationException, 没有友好的提示
+ * -
+ * -
+ * 若入参是Body,需要使用@RequestBody解析到实体/Map内,校验错误抛出到MethodArgumentNotValidException。@RequestBody @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+ * 如入参是formData,不能使用@RequestBody,参数会自动解析到实体; 若不是实体则需要通过方法转Map, 错会抛出到BindException. @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+ * -
+ * 使用@RestController后,请求方法无需再使用@RequestBody,@RequestParam,直接可对入参进行修饰,效果类似解构,除了正常获取HttpServletRequest外,相关参数自动处理到注解内
+ * RESTFul 风格接口, 参数获取方式均相同, GET 没有 body, @PathVariable 获取 url 占位符, RequestParam 获取 url 参数, @RequestBody 获取 body 数据
+ * -
+ * 关于入参为JSONString类型: body内的json需要传入字符串, 若为对象会解析报错. 若是formData, 传入对象会自动转为字符串, 不会解析错误, 无需转为JSONString
+ * 注解@JsonInclude(JsonInclude.Include.NON_NULL):类注解过滤null字段,包含入参和返回值【参数类搭配 @Data,才可以实例化返回值以及ToString输出】
+ */
+@Slf4j
+@RestControllerAdvice(annotations = RestController.class)
+public class CatchException<T> {
+
+//    @Autowired
+//    private ExceptionNotice notice;
+
+    /**
+     * 通用错误类抛出
+     */
+    @ExceptionHandler(McException.class)
+    public McR McException(McException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.R(e.isSuccess(), e.getCode(), e.getMessage(), null, e.getSource());  // IGNORE_EXECUTE 为成功
+    }
+
+    /**************** validated不合法 ****************/
+
+    /**
+     * @validated 验证入参对象 @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     * -
+     * 如入参是formData,不能使用@RequestBody,参数会自动解析到实体; 若不是实体通过方法转Map .报错会抛出到BindException。@Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     */
+    @ExceptionHandler(value = BindException.class)
+    public McR BindException(BindException e) {
+        List errorParam = new ArrayList();
+        e.getFieldErrors().forEach(fieldError -> {
+            errorParam.add(fieldError.getField() + ": " + fieldError.getDefaultMessage());
+        });
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("formData参数 -> " + String.join(", ", errorParam));
+    }
+
+    /**
+     * @Validated 关于在Service上使用校验,
+     * -
+     * 首先需要impl类上添加@Validated [和Controller一致]
+     * 建议注解到方法声明上, 抛出到ConstraintViolationException. 若校验注解到方法实现上, 则错误抛出到 ConstraintDeclarationException, 没有友好的提示
+     */
+    @ExceptionHandler(ConstraintDeclarationException.class)
+    public McR ConstraintDeclarationException(ConstraintDeclarationException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("param参数 -> " + e.getMessage());
+    }
+
+    /**
+     * @validated 验证入参字段 @NotNull(message = "cur不能为空") String cur
+     * -
+     * 若入参是Body,需要使用@RequestBody解析到实体内,校验错误抛出到MethodArgumentNotValidException。@RequestBody @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     * 如入参是formData,不能使用@RequestBody,参数会自动解析到实体; 若不是实体通过方法转Map .报错会抛出到BindException。@Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     * 关于入参为JSONString类型: body内的json需要传入字符串, 若为对象会解析报错. 若是formData, 传入对象会自动转为字符串, 不会解析错误, 无需转为JSONString
+     */
+    @ExceptionHandler(ConstraintViolationException.class)
+    public McR ConstraintViolationException(ConstraintViolationException e) {
+        List errorParam = new ArrayList();
+        e.getConstraintViolations().forEach(fieldError -> {
+            errorParam.add(fieldError.getPropertyPath() + ": " + fieldError.getMessage());
+        });
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("param参数 -> " + String.join(", ", errorParam));
+    }
+
+    /**
+     * @validated 验证入参对象 @RequestBody @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     * -
+     * 若入参是Body,需要使用@RequestBody解析到实体内,校验错误抛出到MethodArgumentNotValidException。@RequestBody @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public McR MethodArgumentNotValidException(MethodArgumentNotValidException e) {
+        List errorParam = new ArrayList();
+        e.getBindingResult().getAllErrors().forEach(err -> {
+            FieldError fieldError = (FieldError) err;
+            errorParam.add(fieldError.getField() + ": " + fieldError.getDefaultMessage());
+        });
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("body参数 -> " + String.join(", ", errorParam));
+    }
+
+    /**************** 请求参数异常 ****************/
+
+    /**
+     * @RequestParam默认是required 验证入参字段 @RequestParam String cur
+     * -
+     * 参数@RequestParam,可以配置默认值,指定name,指定name后可以设置是否必填。若是要获取所有(@RequestParam MultiValueMap<String, String> param
+     */
+    @ExceptionHandler(MissingServletRequestParameterException.class)
+    public McR MissingServletRequestParameterException(MissingServletRequestParameterException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("param参数 -> " + e.getMessage());
+    }
+
+    /**
+     * @RequestParam 参数类型解析异常
+     */
+    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+    public McR MethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("param参数 ->  " + e.getName() + "不合法: " + e.getMessage());
+    }
+
+    /**
+     * @RequestBody 序列化字段为非Sting类型
+     * -
+     * 关于入参为JSONString类型: body内的json需要传入字符串, 若为对象会解析报错. 若是formData, 传入对象会自动转为字符串, 不会解析错误, 无需转为JSONString
+     */
+    @ExceptionHandler(HttpMessageNotReadableException.class)
+    public McR HttpMessageNotReadableException(HttpMessageNotReadableException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        if (e.getMessage().contains("Required request body is missing")) {
+            return McR.errorParam("请求体参数不能为空");
+        }
+        return McR.errorParam("请求体参数格式不合法");
+    }
+
+    /**
+     * Content-Type不合法
+     */
+    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
+    public McR HttpMediaTypeNotSupportedException(HttpMediaTypeNotSupportedException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("Content-Type不合法: " + e.getMessage());
+    }
+
+    /**************** 网络请求异常 ****************/
+
+    /**
+     * http请求返回json解析异常
+     */
+    @ExceptionHandler(JSONException.class)
+    public McR JSONException(JSONException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("返回JSON解析异常: " + e.getMessage());
+    }
+
+    /**************** 数据类型异常 ****************/
+
+    /**
+     * BigDecimal 非数值型字符串, 空字符串取值异常
+     */
+    @ExceptionHandler(NumberFormatException.class)
+    public McR NumberFormatException(NumberFormatException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam(e.getMessage() + ": 非数值型字符串, 空字符串取值异常");
+    }
+
+    /**
+     * 反射未匹配到路径
+     */
+    @ExceptionHandler(ClassNotFoundException.class)
+    public McR ClassNotFoundException(ClassNotFoundException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("反射未匹配到路径: " + e.getMessage());
+    }
+
+    /**
+     * 系统错误抛出
+     * -
+     * NOTE: 空指针判定, 日志目前会记录在warn内, 不输出到error内, 做一层包装返回 (空指针message为null)
+     */
+    @ExceptionHandler(Exception.class)
+    public McR Exception(Exception e) {
+        log.error(e.getMessage(), e);       // 记录错误日志
+//        notice.noticeErrorByDingtalk(e);    // 上报错误日志
+        if (e instanceof NullPointerException) {
+            return McR.errorNullPointer();
+        }
+        return McR.errorUnknown(e.getMessage());
+    }
+}

+ 53 - 0
mjava/src/main/java/com/malk/filter/ExceptionNotice.java

@@ -0,0 +1,53 @@
+//package com.mcli.filter;
+//
+//import cn.hutool.core.util.ObjectUtil;
+//import com.alibaba.fastjson.JSONObject;
+//import com.mcli.com.mcli.mcli.utils.UtilDateTime;
+//import com.mcli.service.common.McConf;
+//import com.mcli.service.common.McException;
+//import com.mcli.service.dingtalk.DDConf;
+//import lombok.extern.slf4j.Slf4j;
+//import org.springframework.beans.factory.annotation.Autowired;
+//import org.springframework.stereotype.Component;
+//
+//import java.util.Date;
+//
+///**
+// * 报错消息: 发送钉钉工作通知
+// */
+//@Slf4j
+//@Component
+//public class ExceptionNotice {
+//
+//    @Autowired
+//    private DDClient ddClient;
+//
+//    @Autowired
+//    private DDConf ddConf;
+//
+//    @Autowired
+//    private McConf mcConf;
+//
+//    /**
+//     * 上报错误日志 [未知错误]
+//     */
+//    public void noticeErrorByDingtalk(Exception e) {
+//        // 通知人为空为空则忽略
+//        if (ObjectUtil.isNull(mcConf.getEngineers()) || mcConf.getEngineers().isEmpty()) {
+//            return;
+//        }
+//        String content = e.getMessage();
+//        if (e instanceof McException) {
+//            McException mcE = (McException) e;
+//            content = mcE.toString();
+//        }
+//        log.warn("上报错误日志, {}, {}", mcConf.getEngineers(), content);
+//        content += " --> " + UtilDateTime.formatDateTime(new Date());
+//        JSONObject message = new JSONObject();
+//        message.put("msgtype", "text");
+//        JSONObject info = new JSONObject();
+//        info.put("content", content);
+//        message.put("text", info);
+//        ddClient.sendCorpConversationMessage(ddConf.getAgentId(), mcConf.getEngineers(), null, false, message);
+//    }
+//}

+ 32 - 0
mjava/src/main/java/com/malk/filter/RequestFilter.java

@@ -0,0 +1,32 @@
+package com.malk.filter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.*;
+import java.io.IOException;
+
+@Component
+public class RequestFilter implements Filter {
+
+    // 指定类输出日志到指定文件夹
+    private static final Logger logger = LoggerFactory.getLogger("point");
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {
+        logger.info("过滤器 ▷ 初始化");
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+        logger.trace("过滤器 ▷ 开始执行");
+        chain.doFilter(request, response);
+        logger.trace("过滤器 ▷ 执行结束");
+    }
+
+    @Override
+    public void destroy() {
+        logger.info("过滤器 ▷ 销毁");
+    }
+}

+ 41 - 0
mjava/src/main/java/com/malk/filter/RequestInterceptor.java

@@ -0,0 +1,41 @@
+package com.malk.filter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.ModelAndView;
+import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 请求拦截器: 继承 HandlerInterceptor 或实现 HandlerInterceptor
+ */
+@Component
+public class RequestInterceptor extends HandlerInterceptorAdapter {
+
+    // 指定类输出日志到指定文件夹
+    private static final Logger logger = LoggerFactory.getLogger("point");
+
+    // 请求拦截
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        logger.info("拦截器 ▷ 收到请求: {}", request.getServletPath());
+        return super.preHandle(request, response, handler);
+    }
+
+    // 视图渲染
+    @Override
+    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
+        logger.trace("拦截器 ▷ 视图渲染");
+        super.postHandle(request, response, handler, modelAndView);
+    }
+
+    // 数据返回
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
+        logger.trace("拦截器 ▷ 数据返回");
+        super.afterCompletion(request, response, handler, ex);
+    }
+}

+ 47 - 0
mjava/src/main/java/com/malk/filter/TraceIdFilter.java

@@ -0,0 +1,47 @@
+package com.malk.filter;
+
+import org.slf4j.MDC;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.UUID;
+
+/**
+ * TraceId 贯穿请求与日志
+ * -
+ * 1. 从请求头 X-Trace-Id 读取,缺失则生成 32 位无横线 UUID
+ * 2. 写入 SLF4J MDC,logback pattern 通过 %X{traceId} 输出
+ * 3. 响应头回写 X-Trace-Id 供客户端/上游关联
+ * 4. 请求结束 finally 清理,防止线程池复用污染
+ */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE + 10)
+public class TraceIdFilter extends OncePerRequestFilter {
+
+    public static final String HEADER = "X-Trace-Id";
+    public static final String MDC_KEY = "traceId";
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request,
+                                    HttpServletResponse response,
+                                    FilterChain chain) throws ServletException, IOException {
+        String traceId = request.getHeader(HEADER);
+        if (traceId == null || traceId.isEmpty()) {
+            traceId = UUID.randomUUID().toString().replace("-", "");
+        }
+        MDC.put(MDC_KEY, traceId);
+        response.setHeader(HEADER, traceId);
+        try {
+            chain.doFilter(request, response);
+        } finally {
+            MDC.remove(MDC_KEY);
+        }
+    }
+}

+ 13 - 0
mjava/src/main/java/com/malk/repository/dao/primary/McAuthorizationDao.java

@@ -0,0 +1,13 @@
+package com.malk.repository.dao.primary;
+
+import com.malk.repository.entity.primary.McAuthorizationPo;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/**
+ * JAP配置参考BaseDao
+ */
+public interface McAuthorizationDao extends JpaRepository<McAuthorizationPo, Long> {
+
+    // findBy, 可直接添加字段名称
+    McAuthorizationPo findByAppType(String appType);
+}

+ 10 - 0
mjava/src/main/java/com/malk/repository/dao/primary/McTableDao.java

@@ -0,0 +1,10 @@
+package com.malk.repository.dao.primary;
+
+import com.malk.repository.entity.primary.McTablePo;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository("primaryMcTableDao")
+public interface McTableDao extends JpaRepository<McTablePo, Long> {
+
+}

+ 11 - 0
mjava/src/main/java/com/malk/repository/dao/slave/McTableDao.java

@@ -0,0 +1,11 @@
+package com.malk.repository.dao.slave;
+
+
+import com.malk.repository.entity.slave.McTablePo;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository("slaveMcTableDao")
+public interface McTableDao extends JpaRepository<McTablePo, Long> {
+
+}

+ 51 - 0
mjava/src/main/java/com/malk/repository/entity/primary/McAuthorizationPo.java

@@ -0,0 +1,51 @@
+package com.malk.repository.entity.primary;
+
+import com.malk.base.BasePo;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.hibernate.validator.constraints.Range;
+
+import javax.persistence.Entity;
+import javax.persistence.Table;
+import javax.validation.constraints.NotNull;
+
+/**
+ * 宜搭授权信息类
+ **/
+@Entity
+@Data
+@NoArgsConstructor
+@Table(name = "mc_authorization")
+public class McAuthorizationPo extends BasePo {
+
+    @Range(min = 0, max = 1)
+    private int delStatus = 0; // 是否删除: 设置默认值
+
+    @NotNull(message = "应用编码不能为空")
+    private String appType;
+
+    @NotNull(message = "应用秘钥不能为空")
+    private String systemToken;
+
+    @NotNull(message = "应用名称不能为空")
+    private String appName;
+
+    @NotNull(message = "授权企业编号不能为空")
+    private String corpId;
+
+    private String corpName;
+
+    @NotNull(message = "申请人用户编号不能为空")
+    private String userId;
+
+    @NotNull(message = "申请人姓名不能为空")
+    private String userName;
+
+    private String deptId;
+
+    private String deptName;
+
+    private String description;
+
+    private String remark;
+}

+ 34 - 0
mjava/src/main/java/com/malk/repository/entity/primary/McTablePo.java

@@ -0,0 +1,34 @@
+package com.malk.repository.entity.primary;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.malk.base.BasePo;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EntityListeners;
+import javax.persistence.Table;
+
+/**
+ * 主表记录
+ */
+@Entity
+@Table(name = "mc_table")
+@EntityListeners(AuditingEntityListener.class)
+@JsonInclude(value = JsonInclude.Include.NON_NULL)
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class McTablePo extends BasePo {
+
+    @Column
+    private String tName;
+
+    @Column
+    private String tDesc;
+}

+ 34 - 0
mjava/src/main/java/com/malk/repository/entity/slave/McTablePo.java

@@ -0,0 +1,34 @@
+package com.malk.repository.entity.slave;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.malk.base.BasePo;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EntityListeners;
+import javax.persistence.Table;
+
+/**
+ * 从表记录
+ */
+@Entity
+@Table(name = "mc_table")
+@EntityListeners(AuditingEntityListener.class)
+@JsonInclude(value = JsonInclude.Include.NON_NULL)
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class McTablePo extends BasePo {
+
+    @Column
+    private String tName;
+
+    @Column
+    private String tDesc;
+}

+ 61 - 0
mjava/src/main/java/com/malk/schedule/McScheduleTask.java

@@ -0,0 +1,61 @@
+package com.malk.schedule;
+
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Event;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+
+/**
+ * 定时任务ScheduleTask
+ * -
+ * 1. @EnableScheduling:开启定时任务,当前文件文件全局注解【也可单独成为一个控制的配置文件管控全局】
+ * 2. @Scheduled:定时执行的方法,通过入参控制
+ * - 1. @Scheduled(fixedDelay = 5000):单位毫秒,当任务执行完毕后5s后再执行
+ * - 2. @Scheduled(fixedRate = 3000):单位毫秒,表示每隔3秒,不受执行时间影响
+ * - 3. cron表达式:cron一共有7位,但是最后一位是年,可以留空,cron中,还有一些特殊的符号
+ * 3. @component / @Configuration:声明定时任务为组件类。若不声明,定时器无效,因为没有注入
+ */
+
+/**
+ * @EnableScheduling 条件注入, 根据条件确定当前类是否要装载Bean: 如定时器开发环境不启动
+ * -
+ * 示例:注意格式 @ConditionalOnExpression(value = "${spel.scheduling}") 或者 @ConditionalOnProperty(name = "spel.scheduling", havingValue = "false")
+ * *
+ * @ConditionalOnExpression,可多个参数,支持与、或关系,默认匹配bool。注意若对应环境未识别到声明会报错
+ * @ConditionalOnProperty,可多个参数,支持与,若获取值为空识别为false,若有值则将该值与havingValue指定的值进行比较,匹配结果为bool。不支持或
+ */
+
+
+// todo L 不识别, 启动报错
+
+@Slf4j
+@Configuration
+@EnableScheduling
+@ConditionalOnProperty(name = {"spel.scheduling"}, havingValue = "false")
+public class McScheduleTask {
+
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private DDClient_Event ddClient_event;
+
+    /**
+     * 钉钉事件回调 3_3
+     * -
+     * 同步钉钉推送失败记录: 推送失败列表, 获取后记录会被清空
+     */
+//    @Scheduled(cron = "0 0/30 7-23 * * ?")
+    public void syncDingTalkFailedList() {
+        try {
+            ddClient_event.syncFailedList(ddClient.getAccessToken());
+        } catch (Exception e) {
+            // 记录错误信息
+            e.printStackTrace();
+        }
+    }
+}

+ 203 - 0
mjava/src/main/java/com/malk/server/aliwork/YDConf.java

@@ -0,0 +1,203 @@
+package com.malk.server.aliwork;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.utils.UtilMap;
+import com.malk.utils.UtilString;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "aliwork")
+@Slf4j
+public class YDConf {
+
+    private String appType;
+
+    private String systemToken;
+
+    /**
+     * 一个切片数量上限
+     */
+    public static final Integer UPPER_LIMIT = 30000;
+
+    /**
+     * 一个分页数量上限
+     */
+    public static final Integer PAGE_SIZE_LIMIT = 100;
+
+
+    /**
+     * 子表超过50分页查询数量
+     */
+    public static final Integer PAGE_SIZE_DETAILS = 50;
+
+
+    /**
+     * 接口访问账号 [不能触发待办与消息通知, 业务规则亦不能]
+     * -
+     * 非管理员账号只可以查询, 不检验权限
+     * 操作数据需要管理员账号, 或宜搭平台
+     */
+    public static final String PUB_ACCOUNT = "yida_pub_account";
+
+    ////////////////////////// 新版本API //////////////////////////
+
+    /**
+     * 查询表单
+     */
+    public enum FORM_QUERY {
+
+        retrieve_list,              // 全局查询, 不包含子表单
+        retrieve_list_all,          // 全局查询, 包含子表单
+        multi_retrieve_id,
+
+        retrieve_id,                // 单个ID查询 todo 若秘钥不匹配, 返回空, 添加报错说明
+
+        retrieve_search_process,            // 流程列表
+        retrieve_search_form,               // 表单列表
+
+        retrieve_search_process_id,         // 流程列表
+        retrieve_search_form_id,             // 表单列表
+
+        retrieve_details,                   // 子表数据[表单]
+        retrieve_changed,               // 变更记录
+        retrieve_definition,        // 表单定义
+
+        retrieve_forms, // 表单列表
+        retrieve_approval_record, // 审批记录
+    }
+
+    /**
+     * 表单操作
+     */
+    public enum FORM_OPERATION {
+        create,
+        delete,                     // 传入为body, 文档为param
+        update,
+
+        // fixme: 不支持新增字段更新, 若有可先更新版本后执行 [无版本和更新json参数];
+        // ppExt: start / create, noExecuteExpression 默认值是 true, 若需要触发配置消息通知, 设置为 false
+        upsert,                     // insertOrUpdate
+        upsert_v2,                  // 新版本 searchCondition 条件精准匹配
+        multi_create,               // 批量操作
+        delete_batch,               // 批量删除
+        multi_update,               // 批量更新
+        start,                      // 发起流程
+        batchSave,                  // 批量创建
+
+        remarks,  // 评论
+    }
+
+    /**
+     * 关联表单处理
+     *
+     * @param formType: "receipt" 跳转为表单【若是流程,则权限体系会失效,但也会显示审批节点】; formType: "process" 为流程
+     * @apiNote ppExt 接口更新, 传入jsonString或List都可以. 返回数据都是两层json解析, 组件 + _id格式
+     */
+    public List<Map> associationForm(String formUuid, String formInstanceId, String title, String subTitle, boolean isProcess) {
+        return associationForm(appType, formUuid, formInstanceId, title, subTitle, isProcess);
+    }
+
+    // todo 部门ID写入需要是string集合\数组, 封装
+    public static List<Map> associationForm(String appType, String formUuid, String formInstanceId, String title, String subTitle, boolean isProcess) {
+        String formType = isProcess ? "process" : "receipt";
+        return Arrays.asList(UtilMap.map("appType, formUuid, instanceId, title, subTitle, formType", appType, formUuid, formInstanceId, title, subTitle, formType));
+    }
+
+    /**
+     * 读取关联表单: ppExt 两层解析后才是List, 若是赋值取值转一层, 宜搭也可进行写入
+     * - 赋值说明 -
+     * 若是同一个应用,appType与formUuid为空(不指定下formUuid可为错误,但appType只能为空),点击页面链接会自动匹配;
+     * 若是跨应用实例,则不能通过导入,需要通过接口指定appType
+     */
+    public static List<Map> associationForm(String associations) {
+        if (UtilString.isBlankCompatNull(associations)) {
+            return new ArrayList<>();
+        }
+        return (List<Map>) JSON.parse(String.valueOf(JSON.parse(String.valueOf(associations))));
+    }
+
+    /**
+     * 高级筛选: ppExt 通过表单\流程 search 宜搭目前下拉框也会被模糊匹配, 为避免事后过滤, 使用高级查询; 且高级查询支持同一个字段多条件
+     */
+    public static Map searchCondition_TextFiled(String compId, Object value, String operator) {
+        return UtilMap.map("key, value, type, operator, componentName", compId, value, "TEXT", operator, "TextField");
+    }
+
+    // todo text组件是否可用于select
+    public static Map searchCondition_processInstanceStatus(List status) {
+        return UtilMap.map("key, value, type, operator, componentName", "processInstanceStatus", status, "ARRAY", "in", "SelectField");
+    }
+
+    /**
+     * 组件格式化取值 [取值] todo 明细表递归, 循环传递入参
+     */
+    public static Object getDataByCompId(Map formData, String compId_cur) {
+        if (compId_cur.contains("associationFormField_")) {
+            // 服务注册 #{_yida_all_data} 直接组件Id
+            if (!formData.containsKey(compId_cur)) {
+                // 接口返回数据, 关联组件ID, 不会返回组件id字段
+                compId_cur = compId_cur + "_id";
+            }
+            return JSON.parse(String.valueOf(formData.get(compId_cur)));
+        } else if (compId_cur.contains("employeeField_")) {
+            if (!formData.containsKey(compId_cur + "_id")) {
+                // 服务注册 #{_yida_all_data} 返回JsonString userid 数组
+                return JSON.parse(String.valueOf(formData.get(compId_cur)));
+            } else {
+                // 成员组件, 接口返回组件id返回为姓名, _id返回是userId
+                return formData.get(compId_cur + "_id");
+            }
+        }
+        return formData.get(compId_cur);
+    }
+
+    ////////////////////////// 老版本API //////////////////////////
+
+    /**
+     * 表单接口适用于流程: 查询和更新以及删除
+     */
+    private static String formatApiForm(String uri) {
+        return "/yida_vpc/form/" + uri + ".json";
+    }
+
+    private static String formatApiProcess(String uri) {
+        return "/yida_vpc/process/" + uri + ".json";
+    }
+
+    /**
+     * 接口地址: 表单
+     */
+    public static final String API_FORM_CREATE = formatApiForm("saveFormData");
+    public static final String API_FORM_DELETE = formatApiForm("deleteFormData");
+    public static final String API_FORM_UPDATE = formatApiForm("updateFormData");
+    public static final String API_FORM_DETAIL = formatApiForm("getFormDataById");
+    public static final String API_FORM_QUERY_ID = formatApiForm("searchFormDataIds");
+    public static final String API_FORM_QUERY_DATA = formatApiForm("searchFormDatas");
+
+    public static final String API_FORM_DEFINITION = "/yida_vpc/formDesign/getFormComponentDefinationList.json";
+
+    /**
+     * 接口地址: 流程
+     */
+    public static final String API_PROCESS_CREATE = formatApiProcess("startInstance");
+    public static final String API_PROCESS_DELETE = formatApiProcess("deleteInstance");
+    public static final String API_PROCESS_UPDATE = formatApiProcess("updateInstance");
+    public static final String API_PROCESS_DETAIL = formatApiProcess("getInstanceById");
+    public static final String API_PROCESS_BATCH_DETAIL = formatApiProcess("getInstancesByIds");
+    public static final String API_PROCESS_QUERY_ID = formatApiProcess("getInstanceIds");
+    public static final String API_PROCESS_QUERY_DATA = formatApiProcess("getInstances");
+
+    /**
+     * 其它接口
+     */
+    public static final String API_OPEN_URL = "/yida_vpc/file/getOpenUrl.json";
+}

+ 240 - 0
mjava/src/main/java/com/malk/server/aliwork/YDParam.java

@@ -0,0 +1,240 @@
+package com.malk.server.aliwork;
+
+import com.alibaba.fastjson.JSONObject;
+import com.malk.base.BaseDto;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Null;
+import javax.validation.groups.Default;
+import java.util.List;
+
+/**
+ * 表单接口适用于流程: 查询和更新以及删除 [参数校验参考CatchException]
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class YDParam extends BaseDto {
+
+    /**
+     * 接口参数
+     */
+    @NotNull(message = "应用编码不能为空")
+    private String appType;
+
+    @Null(message = "应用秘钥无需传递")
+    private String systemToken;
+
+    // 默认宜搭平台, 有操作数据权限
+    @Builder.Default
+    private String userId = YDConf.PUB_ACCOUNT;
+
+    @NotNull(message = "表单编码不能为空", groups = {Create.class, Retrieve_Condition.class, Definition.class, Create_Process.class, Retrieve_Condition_Update.class})
+    private String formUuid;
+
+    /// FIXME: 表单/流程组件查询
+    private String searchFieldJson;
+
+    @Builder.Default
+    private Integer currentPage = 1;
+
+    @Builder.Default
+    private Integer pageSize = YDConf.PAGE_SIZE_LIMIT;
+
+    // 流程暂不支持排序
+    private String dynamicOrder;
+
+    // 修改开始时间, ppExt: 需要同时传递开始和就结束才会生效; 其次日期格式yyyy-MM-dd, 但默认是0点, 结束日期建议加1
+    private String modifiedFromTimeGMT;
+
+    // 修改结束时间, ppExt: 需要同时传递开始和就结束才会生效; 其次日期格式yyyy-MM-dd, 但默认是0点, 结束日期建议加1
+    private String modifiedToTimeGMT;
+
+    @NotNull(message = "实例ID不能为空", groups = {Update.class, Delete.class, Retrieve_FormInstId.class})
+    private String formInstId;
+
+    @NotNull(message = "更新内容不能为空", groups = {Update.class, Update_ProcessInstanceId.class, Retrieve_Condition_Update.class})
+    private String updateFormDataJson;
+
+    @NotNull(message = "流程编码不能为空", groups = Create_Process.class)
+    private String processCode;
+
+    // todo: 成员组件未匹配, 接口可以新增 [系统会忽略], 导入会提示无法匹配
+    @NotNull(message = "新增内容不能为空", groups = {Create.class, Create_Process.class})
+    public String formDataJson;
+
+    @NotNull(message = "实例ID不能为空", groups = {Update_ProcessInstanceId.class, Delete_ProcessInstanceId.class, Retrieve_ProcessInstanceId.class})
+    private String processInstanceId;
+
+    @NotNull(message = "实例ID不能为空", groups = Retrieve_ProcessInstanceIds.class)
+    private String processInstanceIds;
+
+    /// RUNNING / NEW / PAUSED / TERMINATED / COMPLETED / ERROR / CANCELED
+    private String instanceStatus;
+
+    /// disagree / agree
+    private String approvedResult;
+
+    /**
+     * 格式化赋值: 内部使用
+     */
+    public void setDynamicOrder(String dynamicOrder) {
+        this.dynamicOrder = dynamicOrder;
+    }
+
+    public void setDynamicOrder(String rule, String... compIds) {
+        for (String compId : compIds) {
+            JSONObject orderJson = new JSONObject();
+            orderJson.put(compId, rule);
+            this.dynamicOrder = orderJson.toJSONString();
+        }
+    }
+
+    /// 查询创建时间, 钉钉版本接口 [旧版本接口不支持]
+    private String createFromTimeGMT;
+    private String createToTimeGMT;
+
+
+    /**
+     * 自定义参数
+     */
+    private String compIdSerial; // 流水号排序字段
+
+    /**
+     * 钉钉新版本接口
+     */
+    @Builder.Default
+    private Integer pageNumber = 1;
+
+    private String formInstanceId;
+
+    // formInstanceId 为新版本实例id字段
+    public String getFormInstanceId() {
+        if (StringUtils.isBlank(formInstanceId)) {
+            return formInstId;
+        }
+        return formInstanceId;
+    }
+
+    /// FIXME: 表单/流程全局查询
+    private String searchCondition;
+
+    // 全局条件查询列表数据 FIXME: 若是查询组件也传searchFieldJson格式
+    public String getSearchCondition() {
+        if (StringUtils.isBlank(searchCondition)) {
+            return searchFieldJson;
+        }
+        return searchCondition;
+    }
+
+    // 查询列表可查询多个指定实例ID | 批量删除 [上限50] | 批量更新
+    private List<String> formInstanceIdList;
+
+    // 是否需要宜搭表单组件格式的实例数据
+    private boolean needFormInstanceValue = false;
+
+    // 查询明细数据列表, 默认仅返回前50
+    private String tableFieldId;
+
+    // 附件转临时免登地址
+    private String fileUrl;
+    // 临时地址失效时间, 默认10分钟, 最大值24小时
+    @Builder.Default
+    private Integer timeout = 600000;
+
+    /**
+     * 批量删除
+     */
+
+    // 是否需要宜搭服务端异步执行该任务
+    @Builder.Default
+    private boolean asynchronousExecution = false;
+
+    // 是否需要触发表单绑定的校验规则、关联业务规则和第三方服务回调
+    @Builder.Default
+    private boolean executeExpression = false;
+
+    // 是否不触发表单绑定的校验规则、关联业务规则和第三方服务回调。
+    @Builder.Default
+    private boolean noExecuteExpression = true;
+
+    // 是否忽略空值。
+    @Builder.Default
+    private boolean ignoreEmpty = true;
+
+    // 是否使用最新的表单schema版本。
+    @Builder.Default
+    private boolean useLatestFormSchemaVersion = true;
+
+    // 使用最新的表单版本进行更新。
+    @Builder.Default
+    private boolean useLatestVersion = false;
+
+    /**
+     * 评论
+     */
+    private String atUserId;
+
+    private String content;
+
+
+    /**
+     * 分组校验
+     *
+     * @RequestBody @Validated(YDParam.Create.class) YDParam ydParam
+     */
+
+    public interface Create extends Default {
+
+    }
+
+    public interface Create_Process extends Default {
+
+    }
+
+    public interface Retrieve_Condition extends Default {
+
+    }
+
+    public interface Retrieve_Condition_Update extends Default {
+
+    }
+
+    public interface Retrieve_FormInstId extends Default {
+
+    }
+
+    public interface Retrieve_ProcessInstanceId extends Default {
+
+    }
+
+    public interface Retrieve_ProcessInstanceIds extends Default {
+
+    }
+
+    public interface Update extends Default {
+
+    }
+
+    public interface Update_ProcessInstanceId extends Default {
+
+    }
+
+    public interface Delete extends Default {
+
+    }
+
+    public interface Delete_ProcessInstanceId extends Default {
+
+    }
+
+    public interface Definition extends Default {
+
+    }
+}

+ 32 - 0
mjava/src/main/java/com/malk/server/aliwork/YDR.java

@@ -0,0 +1,32 @@
+package com.malk.server.aliwork;
+
+import com.malk.utils.UtilMap;
+import lombok.Data;
+import org.springframework.data.domain.Page;
+
+import java.util.Map;
+
+/**
+ * * 返回数据_宜搭
+ **/
+@Data
+public class YDR {
+
+    private boolean success;
+
+    private String errorCode;
+
+    private String errorMsg;
+
+    // 查询为data之集合, 新增为实例Id, 删除/更新为空, ....
+    private Object result;
+
+    /**
+     * 宜搭表格分页管理
+     */
+    public static final Map formatPage(Page<Map> page) {
+        Map data = UtilMap.map("currentPage, pageSize, totalCount", page.getNumber() + 1, page.getNumberOfElements(), page.getTotalElements());
+        data.put("data", page.getContent());
+        return data;
+    }
+}

+ 33 - 0
mjava/src/main/java/com/malk/server/aliyun/ALYR.java

@@ -0,0 +1,33 @@
+package com.malk.server.aliyun;
+
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+
+/**
+ * 返回数据_阿里云
+ */
+@Data
+public class ALYR<T> extends VenR {
+
+    private boolean success;
+
+    private String code;
+
+    private String message;
+
+    private String description;
+
+    private T data;
+
+    // 成功状态标记
+    private final static String SUC_CODE = "0";
+
+    /**
+     * 断言错误信息
+     */
+    @Override
+    public void assertSuccess() {
+        McException.assertException(!code.equals(SUC_CODE), code, message + " >>> " + description, "aliyun");
+    }
+}

+ 42 - 0
mjava/src/main/java/com/malk/server/beisen/BSConf.java

@@ -0,0 +1,42 @@
+package com.malk.server.beisen;
+
+import com.malk.utils.UtilMap;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 北森配置
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "beisen")
+public class BSConf {
+
+    private String tenantId;
+
+    private String appKey;
+
+    private String appSecret;
+
+    private String baseUrl;
+
+    /**
+     * Token有效期(秒)
+     */
+    public static final int TOKEN_EXPIRE = 7200;
+
+    /**
+     * 默认分页大小
+     */
+    public static final int DEFAULT_PAGE_SIZE = 100;
+
+    /**
+     * token授权参数
+     */
+    public static Map initTokenHeader(String accessToken) {
+        return UtilMap.map("Authorization", "Bearer " + accessToken);
+    }
+}

+ 42 - 0
mjava/src/main/java/com/malk/server/common/FilePath.java

@@ -0,0 +1,42 @@
+package com.malk.server.common;
+
+import lombok.Data;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.stereotype.Component;
+
+/**
+ * 读取配置文件参考McConf
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "file")
+public class FilePath {
+
+    @Autowired
+    private Path path;
+
+    @Autowired
+    private Source source;
+
+    @Data
+    @Configuration
+    @ConfigurationProperties(prefix = "file.path")
+    public class Path {
+
+        private String file;
+
+        private String image;
+
+        private String tmp;
+    }
+
+    @Data
+    @Configuration
+    @ConfigurationProperties(prefix = "file.source")
+    public class Source {
+
+        private String fonts;
+    }
+}

+ 87 - 0
mjava/src/main/java/com/malk/server/common/McConf.java

@@ -0,0 +1,87 @@
+package com.malk.server.common;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 读取配置文件
+ *
+ * @Value("${ }") :变量读取方式,尤其注意不支持读取yml文件内容,可通过在变量后 :10 设置默认值为 10
+ * 添加的属性前,为该字段赋值:@Value("${dingding.appKey}"
+ * 需要3个前提: 不能使用static或final修饰了tagValue; 类没有加上@Component(或者@service等); 类被new新建了实例,而没有使用@Autowire
+ * * --------------------------------------------------------------------------------- *
+ * @ConfigurationProperties(prefix = "pre") 对象读取方式: 实例化承载对象
+ * 将指定前缀配置实例化为实体类 [添加 spring-boot-configuration-processor依赖避免idea报错]
+ * 尤其注意: 需要为类添加组件注解, 如 @Service, @Component, ...; 引用实体类必须通过 @Autowired 进行注入, 否则识别到为空 [使用注意与 @Value 相同]
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "corp")
+public class McConf {
+
+    private int timeOut;
+
+    private int timeAwait;
+
+    private List<String> engineers;
+
+    /**
+     * 最大分页
+     */
+    public static int pageSize = 100;
+
+    /**
+     * 分页数据
+     */
+    public static PageRequest simplePage(@Nullable Map param) {
+        int page = Integer.parseInt(String.valueOf(Optional.ofNullable(param.get("page")).orElse("1")));
+        int size = Integer.parseInt(String.valueOf(Optional.ofNullable(param.get("size")).orElse(pageSize)));
+        page -= 1;
+        if (page < 0) {
+            page = 0;
+        }
+        if (size > pageSize) {
+            size = pageSize;
+        }
+        if (size < 1) {
+            size = 1;
+        }
+        return PageRequest.of(page, size);
+    }
+
+    /**
+     * 全部数据
+     */
+    public static PageRequest allDataPage() {
+        return PageRequest.of(0, Integer.MAX_VALUE);
+    }
+
+    /**
+     * 单条数据
+     */
+    public static PageRequest singleDataPage() {
+        return PageRequest.of(0, 1);
+    }
+
+    /**
+     * 简单分页: 先查询再分页, 用于数据组装场景
+     */
+    public static List manualPage(PageRequest pageRequest, List dataList) {
+        long total = dataList.size();
+        if (pageRequest.getPageSize() < total) {
+            int start = pageRequest.getPageNumber() * pageRequest.getPageSize();
+            if (start > total) start = (int) total;
+            int end = (pageRequest.getPageNumber() + 1) * pageRequest.getPageSize();
+            if (end > total) end = (int) total;
+            dataList = dataList.subList(start, end);
+        }
+        return dataList;
+    }
+}

+ 168 - 0
mjava/src/main/java/com/malk/server/common/McException.java

@@ -0,0 +1,168 @@
+package com.malk.server.common;
+
+import com.malk.utils.UtilServlet;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Map;
+
+/**
+ * 通用错误类 [错误抛出与拦截详见 CatchException]
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class McException extends RuntimeException {
+
+    private static final long serialVersionUID = 5131110381034275224L;
+
+    private boolean success;
+
+    private String code;
+
+    private String message;
+
+    // 错误来源
+    @Builder.Default
+    private String source = "MC";
+
+    public McException(McREnum rEnum) {
+        this(rEnum.isSuc(), rEnum.getCode(), rEnum.getMsg());
+    }
+
+    public McException(String code, String message) {
+        this(false, code, message);
+    }
+
+    public McException(boolean suc, String code, String message) {
+        super(message);
+        this.success = suc;
+        this.code = code;
+        this.message = message;
+    }
+
+
+    /// 断言错误: 自定义错误信息 ///
+
+    /**
+     * 错误断言: 枚举
+     */
+    public static void assertException(boolean isAssert, McREnum rEnum) {
+        if (isAssert) {
+            throw McException.builder().code(rEnum.getCode()).message(rEnum.getMsg()).build();
+        }
+    }
+
+    /**
+     * 错误断言: 信息
+     */
+    public static void assertException(boolean isAssert, String code, String message) {
+        if (isAssert) {
+            throw McException.builder().code(code).message(message).build();
+        }
+    }
+
+    /**
+     * 错误断言: 来源
+     */
+    public static void assertException(boolean isAssert, String code, String message, String source) {
+        if (isAssert) {
+            throw McException.builder().code(code).message(message).source(source).build();
+        }
+    }
+
+    /**
+     * 断言: 业务校验不通过
+     */
+    public static void
+    assertAccessException(boolean isAssert, String message) {
+        if (isAssert) {
+            throw McException.builder().code(McREnum.VALIDATED_ACCESS.getCode()).message(message).build();
+        }
+    }
+
+    /**
+     * 断言: 参数校验不通过
+     */
+    public static void assertParamException(boolean isAssert, String message) {
+        if (isAssert) {
+            throw McException.builder().code(McREnum.VALIDATED_PARAM.getCode()).message(message).build();
+        }
+    }
+
+    /**
+     * 断言: 参数不合法
+     * -
+     * 实例: McException.assertParamException_Null(UtilServlet.isNull(param, "projectName", "userName"));
+     */
+    public static void assertParamException_Null(String key) {
+        if (StringUtils.isNotBlank(key)) {
+            throw McException.builder().code(McREnum.VALIDATED_PARAM.getCode()).message(("参数不能为空: ").concat(key)).build();
+        }
+    }
+
+    public static void assertParamException_Null(Map param, String... keys) {
+        McException.assertParamException_Null(UtilServlet.isNull(param, keys));
+    }
+
+    public static void assertParamException_Null(Map param, String keys) {
+        McException.assertParamException_Null(UtilServlet.isNull(param, keys));
+    }
+
+    /**
+     * 断言: 参数不能为空
+     */
+    public static void assertParamException_Rule(String key) {
+        if (StringUtils.isNotBlank(key)) {
+            throw McException.builder().code(McREnum.VALIDATED_PARAM.getCode()).message(("参数不合法: ").concat(key)).build();
+        }
+    }
+
+    /// 快速枚举: 自定义错误信息 ///
+
+    /**
+     * 参数不合法
+     */
+    public static McR exceptionParam(String message) {
+        throw McException.builder().code(McREnum.VALIDATED_PARAM.getCode()).message(message).build();
+    }
+
+    /**
+     * 无效token
+     */
+    public static McR exceptionToken(String message) {
+        throw McException.builder().code(McREnum.TOKEN_INVALID.getCode()).message(message).build();
+    }
+
+    /**
+     * 授权无效
+     */
+    public static McR exceptionAuth(String message) {
+        throw McException.builder().code(McREnum.NOT_AUTHORIZED.getCode()).message(message).build();
+    }
+
+    /**
+     * 业务校验不通过
+     */
+    public static McR exceptionAccess(String message) {
+        throw McException.builder().code(McREnum.VALIDATED_ACCESS.getCode()).message(message).build();
+    }
+
+    /**
+     * 方法执行失败
+     */
+    public static McR exceptionExecute(String message) {
+        throw McException.builder().code(McREnum.METHOD_EXECUTE.getCode()).message(message).build();
+    }
+
+    /**
+     * 未知错误
+     */
+    public static McR exceptionUnknown(String message) {
+        throw McException.builder().code(McREnum.UNKNOWN_EXCEPTION.getCode()).message(message).build();
+    }
+}

+ 40 - 0
mjava/src/main/java/com/malk/server/common/McPage.java

@@ -0,0 +1,40 @@
+package com.malk.server.common;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+
+/**
+ * 分页集合数据结构
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class McPage {
+
+    private int page = 1;
+
+    private int size;
+
+    private long total = 0;
+
+    private List list;
+
+    public static McPage page(Page page) {
+        return page(page, page.getContent());
+    }
+
+    public static McPage page(Page page, List dataList) {
+        return McPage.builder()
+                .total(page.getTotalElements())
+                .page(page.getNumber())
+                .size(page.getSize())
+                .list(dataList)
+                .build();
+    }
+}

+ 105 - 0
mjava/src/main/java/com/malk/server/common/McR.java

@@ -0,0 +1,105 @@
+package com.malk.server.common;
+
+import com.malk.base.BaseDto;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 定义返回值结构信息
+ * - java使用了未经检查或不安全的操作,编译会有打印,如泛型 T
+ * 单个文件屏蔽: @SuppressWarnings("unchecked") 。注解在类上编辑就不会有输出
+ * 插件显示警告: 配置 -Xlint:unchecked, 显示具体报错警告位置, 如 Map, List 也会报警告
+ */
+@Data
+@Builder
+public class McR<T> extends BaseDto {
+
+    private boolean success;
+    private String code;
+    private String message;
+    private T data;
+
+    // 错误来源
+    private String source;
+
+    /// 错误实例 ///
+
+    public McR(McREnum rEnum, T data) {
+        this(rEnum.isSuc(), rEnum.getCode(), rEnum.getMsg(), data);
+    }
+
+    public McR(boolean success, String code, String message, T data, String source) {
+        this.success = success;
+        this.code = code;
+        this.message = message;
+        this.data = data;
+        this.source = source;
+    }
+
+    public McR(boolean success, String code, String message, T data) {
+        this(success, code, message, data, null);
+    }
+
+    /// 静态访问 ///
+
+    public static McR R(McREnum rEnum) {
+        return new McR(rEnum, null);
+    }
+
+    public static McR R(McREnum rEnum, Object data) {
+        return new McR(rEnum, data);
+    }
+
+    public static McR R(boolean success, String code, String message, Object data, String source) {
+        return new McR(success, code, message, data, source);
+    }
+
+    /// 快速访问: 实例 ///
+
+    public static McR error(String code, String message) {
+        return R(false, code, message, null, null);
+    }
+
+    public static McR errorParam(String message) {
+        return error(McREnum.VALIDATED_PARAM.getCode(), message);
+    }
+
+    public static McR errorAccess(String message) {
+        return error(McREnum.VALIDATED_ACCESS.getCode(), message);
+    }
+
+    public static McR errorAuth(String message) {
+        return error(McREnum.NOT_AUTHORIZED.getCode(), message);
+    }
+
+    public static McR errorVendor(String message, String source) {
+        return R(false, McREnum.VENDOR_ERROR.getCode(), message, null, source);
+    }
+
+    public static McR errorUnknown(String message) {
+        return error(McREnum.UNKNOWN_EXCEPTION.getCode(), message);
+    }
+
+    /// 快速访问: 枚举 ///
+
+    public static McR success() {
+        return R(McREnum.SUCCESS);
+    }
+
+    public static McR success(Object data) {
+        return R(McREnum.SUCCESS, data);
+    }
+
+    public static McR errorIgnore() {
+        return R(McREnum.IGNORE_EXECUTE);
+    }
+
+    public static McR errorToken() {
+        return R(McREnum.TOKEN_INVALID);
+    }
+
+    public static McR errorNullPointer() {
+        return R(McREnum.NULL_POINTER);
+    }
+}
+

+ 33 - 0
mjava/src/main/java/com/malk/server/common/McREnum.java

@@ -0,0 +1,33 @@
+package com.malk.server.common;
+
+import lombok.Getter;
+
+/**
+ * 定义返回值和对应状态的信息
+ */
+public enum McREnum {
+
+    SUCCESS(true, "200", "SUCCESS"),
+    IGNORE_EXECUTE(true, "200", "IGNORE THE CURRENT EXECUTE RESULT"),
+    VALIDATED_PARAM(false, "4001", "PARAMETER VERIFICATION FAILS"),
+    TOKEN_INVALID(false, "4002", "TOKEN INVALID"),
+    NOT_AUTHORIZED(false, "4003", "ERROR INVALID AUTH STATE"),
+    VALIDATED_ACCESS(false, "4004", "VALIDATE IS DISSATISFY THE CONDITION"),
+    METHOD_EXECUTE(false, "5001", "ENCOUNTER AN ERROR WHEN EXECUTE METHOD"),
+    NULL_POINTER(false, "5002", "ENCOUNTER AN ERROR NULL POINTER EXCEPTION"),
+    VENDOR_ERROR(false, "5004", "VENDOR PLATFORM ERROR EXCEPTION"),
+    UNKNOWN_EXCEPTION(false, "6001", "THIS IS AN UNKNOWN EXCEPTION");
+
+    @Getter
+    private String code;
+    @Getter
+    boolean suc;
+    @Getter
+    private String msg;
+
+    McREnum(boolean suc, String code, String msg) {
+        this.suc = suc;
+        this.code = code;
+        this.msg = msg;
+    }
+}

+ 56 - 0
mjava/src/main/java/com/malk/server/common/VenR.java

@@ -0,0 +1,56 @@
+package com.malk.server.common;
+
+import com.malk.base.BaseDto;
+import com.malk.utils.UtilHttp;
+import lombok.Data;
+import lombok.SneakyThrows;
+
+import java.util.Map;
+
+/**
+ * 定义第三方返回值结构信息
+ */
+@Data
+public class VenR extends BaseDto {
+
+    /**
+     * 断言错误信息: 子类实现 [静态代理]
+     */
+    public void assertSuccess() {
+
+    }
+
+    ///-- 反射 [通过Class.forName(全类名)方式获取Class]
+
+    public static final String RC_MC = "com.malk.server.common.MCR";
+    public static final String RC_YD = "com.malk.server.aliwork.YDR";
+    public static final String RC_ALY = "com.malk.server.aliyun.ALYR";
+    public static final String RC_DD = "com.malk.server.dingtalk.DDR";
+    public static final String RC_DD_New = "com.malk.server.dingtalk.DDR_New";
+    public static final String RC_EKB = "com.malk.server.ekuaibao.EKBRR";
+    public static final String RC_FXK = "com.malk.server.fxiaoke.FXKR ";
+    public static final String RC_XBB = "com.malk.server.xbongbong.XBBR";
+    public static final String RC_VK = "com.malk.server.vika.VKR";
+    public static final String RC_TB = "com.malk.server.teambition.TBR";
+
+    /**
+     * 通用post请求
+     */
+    @SneakyThrows
+    public static VenR doPost(String url, Map header, Map param, Map body, String path) {
+        Class rClass = Class.forName(path);
+        VenR rsp = UtilHttp.doPost(url, header, param, body, rClass);
+        return rsp;
+    }
+
+    /**
+     * 通用get请求
+     */
+    @SneakyThrows
+    public static VenR doGet(String url, Map header, Map param, String path) {
+        Class rClass = Class.forName(path);
+        VenR rsp = UtilHttp.doGet(url, header, param, rClass);
+        return rsp;
+    }
+}
+

+ 86 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDConf.java

@@ -0,0 +1,86 @@
+package com.malk.server.dingtalk;
+
+import com.malk.utils.UtilMap;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 读取配置文件参考FilePah
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "dingtalk")
+public class DDConf {
+
+    private Number agentId;
+
+    private String appKey;
+
+    private String appSecret;
+
+    private String corpId;
+
+    private String corpToken;
+
+    private String aesKey;
+
+    private String token;
+
+    // 操作人, 需要为OA后台管理员
+    private String operator;
+
+    // 机器人编号
+    private String robotCode;
+
+    /**
+     * 钉钉一级部门: 1
+     */
+    public static final long TOP_DEPARTMENT = 1L;
+
+    /**
+     * 知识库权限设置成员上限: 30
+     */
+    public static final Integer WORKSPACE_PERMISSION_SIZE = 30;
+
+    /**
+     * 钉钉回调响应
+     */
+    public static final String CALLBACK_RESPONSE = "success";
+
+    /**
+     * 验证注册地址, 通过开发平台配置触发
+     */
+    public static final String CALLBACK_CHECK = "check_url";
+
+    /**
+     * 审批任务回调 [审批任务开始、结束、转交]
+     */
+    public static final String BPMS_TASK_CHANGE = "bpms_task_change";
+
+    /**
+     * 审批实例回调 [审批实例开始、结束]
+     */
+    public static final String BPMS_INSTANCE_CHANGE = "bpms_instance_change";
+
+    /**
+     * 员工打卡事件回调
+     */
+    public static final String ATTENDANCE_CHECK_RECORD = "attendance_check_record";
+
+    /**
+     * token授权参数: 旧版本
+     */
+    public static Map initTokenParams(String access_token) {
+        return UtilMap.map("access_token", access_token);
+    }
+
+    /**
+     * token授权参数: 新版本
+     */
+    public static Map initTokenHeader(String access_token) {
+        return UtilMap.map("x-acs-dingtalk-access-token", access_token);
+    }
+}

+ 78 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDConfigSign.java

@@ -0,0 +1,78 @@
+package com.malk.server.dingtalk;
+
+import lombok.SneakyThrows;
+
+import java.net.URL;
+import java.net.URLDecoder;
+import java.security.MessageDigest;
+import java.util.Formatter;
+import java.util.Random;
+
+/**
+ * 计算dd.config的签名参数
+ **/
+public class DDConfigSign {
+
+    /**
+     * 计算dd.config的签名参数
+     *
+     * @param jsticket  通过微应用appKey获取的jsticket
+     * @param nonceStr  自定义固定字符串
+     * @param timeStamp 当前时间戳
+     * @param url       调用dd.config的当前页面URL
+     */
+    @SneakyThrows
+    public static String sign(String jsticket, String nonceStr, long timeStamp, String url) {
+        String plain = "jsapi_ticket=" + jsticket + "&noncestr=" + nonceStr + "&timestamp=" + timeStamp + "&url=" + decodeUrl(url);
+        MessageDigest sha1 = MessageDigest.getInstance("SHA-256");
+        sha1.reset();
+        sha1.update(plain.getBytes("UTF-8"));
+        return byteToHex(sha1.digest());
+    }
+
+    // 字节数组转化成十六进制字符串
+    private static String byteToHex(final byte[] hash) {
+        Formatter formatter = new Formatter();
+        for (byte b : hash) {
+            formatter.format("%02x", b);
+        }
+        String result = formatter.toString();
+        formatter.close();
+        return result;
+    }
+
+    /**
+     * 因为ios端上传递的url是encode过的,android是原始的url。开发者使用的也是原始url,
+     * 所以需要把参数进行一般urlDecode
+     */
+    private static String decodeUrl(String url) throws Exception {
+        URL urler = new URL(url);
+        StringBuilder urlBuffer = new StringBuilder();
+        urlBuffer.append(urler.getProtocol());
+        urlBuffer.append(":");
+        if (urler.getAuthority() != null && urler.getAuthority().length() > 0) {
+            urlBuffer.append("//");
+            urlBuffer.append(urler.getAuthority());
+        }
+        if (urler.getPath() != null) {
+            urlBuffer.append(urler.getPath());
+        }
+        if (urler.getQuery() != null) {
+            urlBuffer.append('?');
+            urlBuffer.append(URLDecoder.decode(urler.getQuery(), "utf-8"));
+        }
+        return urlBuffer.toString();
+    }
+
+    /// test
+    public static String getRandomStr(int count) {
+        String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+        Random random = new Random();
+        StringBuffer sb = new StringBuffer();
+        for (int i = 0; i < count; i++) {
+            int number = random.nextInt(base.length());
+            sb.append(base.charAt(number));
+        }
+        return sb.toString();
+    }
+}

+ 79 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDFormComponentDto.java

@@ -0,0 +1,79 @@
+package com.malk.server.dingtalk;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.utils.UtilMap;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 钉钉组件要求
+ * -
+ * 1. 数字组件不能传null或空, 计算字段会自动计算, 不用传值有可. 但打印不支持会不显示
+ * 2. 撤回和提交字段必填设置, 接口调用也会受控. 但若发起页面只读, 审批节点必填则不影响
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class DDFormComponentDto {
+
+    /**
+     * 组件名称
+     */
+    private String name;
+
+    /**
+     * 组件的值
+     */
+    private String value;
+
+    /**
+     * todo: 钉钉回调, 数字组件不能传null或空, 文本组件null过滤为空; 计算字段胡自动计算, 不用传值, 日期仅支持为年月日或年月日时分两种格式, 人员组件JSON.toJSONString(Arrays.asList(userId))))
+     * 快速格式化, 若value非直接取值需预处理好
+     *
+     * @param formData   表单数据源, 若Value类型为list, 则触发ruleDetail
+     * @param formRule   表单数据格式: { 字段名: 组件名 }
+     * @param detailRule 表单明细数据格式: 单子表 { 字段名: 组件名 }; 多明细 { 明细字段:  { 字段名: 组件名 } }, 明细字段保持和数据字段一致
+     */
+    public static List<DDFormComponentDto> formatComponentValues(Map<String, ?> formData, Map<String, String> formRule, Map detailRule) {
+        // 审批流表单参数,设置各表单项值
+        Map<String, String> ruleDetail = detailRule;
+        List<DDFormComponentDto> formComponentValues = new ArrayList<>();
+        for (String key : formRule.keySet()) {
+            if (formData.get(key) instanceof List) {
+                // 明细组件: 每一个组件都是集合, table为集合嵌套集合
+                List<List<DDFormComponentDto>> formComponentDetailsValues = new ArrayList<>();
+                List<Map> details = (List<Map>) formData.get(key);
+                if (detailRule.get(key) instanceof Map) {
+                    ruleDetail = (Map<String, String>) detailRule.get(key);
+                }
+                for (Map detail : details) {
+                    List<DDFormComponentDto> detailComponentValues = new ArrayList<>();
+                    for (String sub : ruleDetail.keySet()) {
+                        detailComponentValues.add(DDFormComponentDto.builder().name(ruleDetail.get(sub)).value(String.valueOf(detail.get(sub))).build());
+                    }
+                    formComponentDetailsValues.add(detailComponentValues);
+                }
+                formComponentValues.add(DDFormComponentDto.builder().name(formRule.get(key)).value(JSON.toJSONString(formComponentDetailsValues)).build());
+            } else {
+                formComponentValues.add(DDFormComponentDto.builder().name(formRule.get(key)).value(String.valueOf(formData.get(key))).build());
+            }
+        }
+        return formComponentValues;
+    }
+
+    /**
+     * 快速格式化, 上传釘盘的文件转为OA审批对象
+     */
+    public static String formatAttachment(Map dentry) {
+        Map data = UtilMap.map("spaceId, fileName, fileSize, fileType, fileId", "spaceId, name, size, extension, id", dentry);
+        return JSON.toJSONString(Arrays.asList(data));
+    }
+}

+ 24 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDInterActiveCard.java

@@ -0,0 +1,24 @@
+package com.malk.server.dingtalk;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.malk.utils.UtilMap;
+import org.apache.commons.collections4.map.HashedMap;
+
+import java.util.Map;
+
+public class DDInterActiveCard {
+
+    /**
+     * 格式交互卡片, 表格数据类型 [兼容多表格], 格式详见 DDClient_Extension 发送卡片
+     */
+    public static Map formCardDataForTable(Map data, String... props) {
+
+        Map cardData = new HashedMap();
+        for (String prop : props) {
+            cardData.put(prop, UtilMap.map("data, meta", data.get(prop), data.get("meta")));
+        }
+        /// fastjson 避免循环引用: 当一个对象包含另一个对象时 或 当一个对象和另一个对象完全相同时
+        return UtilMap.map("cardParamMap", UtilMap.map("sys_full_json_obj", JSON.toJSONString(cardData, SerializerFeature.DisableCircularReferenceDetect)));
+    }
+}

+ 94 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDR.java

@@ -0,0 +1,94 @@
+package com.malk.server.dingtalk;
+
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 返回值配置参考McR
+ */
+@Data
+@NoArgsConstructor
+public class DDR<T> extends VenR {
+
+    // 请求状态
+    private boolean success;
+
+    private String errcode;
+
+    private String errmsg;
+
+    private T result;
+
+    /**
+     * token
+     */
+    private String accessToken;
+    private int expiresIn;
+
+    /**
+     * ticket
+     */
+    private String ticket;
+
+    /**
+     * 审批实例ID
+     */
+    private String processInstanceId;
+
+    /**
+     * 审批实例详情
+     */
+    private Map processInstance;
+
+    /**
+     * 回调失败事件
+     */
+    private List<Map> failedList;
+
+    private boolean hasMore;
+
+    private String corpid;
+
+    /**
+     * 发送工作通知
+     */
+    private String task_id; // 可调用获取工作通知消息的发送结果查询结果
+
+    /**
+     * 考勤打卡数据
+     */
+    private List<Map> recordresult;
+
+    // 成功状态标记
+    private final static String SUC_CODE = "0";
+
+    // 创建用户邀请成功提示
+    private final static String USER_CREATE = "40103";
+
+    /**
+     * 断言错误信息
+     */
+    @Override
+    public void assertSuccess() {
+        McException.assertException(!errcode.equals(SUC_CODE) && !errcode.equals(USER_CREATE), errcode, errmsg, "dingtalk");
+    }
+
+    /**
+     * 通用post请求
+     */
+    public static DDR doPost(String url, Map header, Map param, Map body) {
+        return (DDR) DDR.doPost(url, header, param, body, VenR.RC_DD);
+    }
+
+    /**
+     * 通用get请求
+     */
+    public static DDR doGet(String url, Map header, Map param) {
+        return (DDR) DDR.doGet(url, header, param, VenR.RC_DD);
+    }
+}

+ 175 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDR_New.java

@@ -0,0 +1,175 @@
+package com.malk.server.dingtalk;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 返回值配置参考McR
+ */
+@Data
+@NoArgsConstructor
+public class DDR_New<T> extends VenR {
+
+    private String code;
+
+    private String message;
+
+    private Object data;
+
+    private boolean success;
+
+    private T result;
+
+    /**
+     * 用户 token 授权
+     */
+    private String accessToken;
+    private String refreshToken; // 生成的refresh_token。可以使用此刷新token,定期的获取用户的accessToken: 过期时间 30 天
+    private long expiresIn;
+    private String corpId;
+
+    /**
+     * 无权限对应CODE
+     */
+    private Map accessdenieddetail;
+
+    /**
+     * 离职记录列表
+     */
+    private List<Map<String, String>> records;
+
+    /**
+     * 审批实例id
+     */
+    private String instanceId;
+
+    ////  宜搭数据  ////
+
+    /**
+     * 列表查询数据
+     */
+    private long pageNumber;
+
+    private long totalCount;
+
+    /**
+     * 实例ID详情
+     */
+    private Map originator;
+
+    private String modifiedTimeGMT;
+
+    private String formInstId;
+
+    private Map formData;
+
+    /**
+     * 变更记录
+     */
+    private Map operationLogMap;
+
+
+    ////  储存空间  ////
+
+    /**
+     * 上传唯一标识
+     */
+    private String uploadKey;
+
+    /**
+     * 文件存储类型。DINGTALK:钉钉统一存储驱动, ALIDOC:钉钉文档存储驱动, UNKNOWN:未知驱动
+     */
+    private String storageDriver;
+
+    /**
+     * 上传协议。HEADER_SIGNATURE:Header加签
+     */
+    private String protocol;
+
+    /**
+     * Header加签上传信息。说明: 当protocol参数传HEADER_SIGNATURE时,返回该字段
+     */
+    private Map headerSignatureInfo;
+
+    /**
+     * 文件信息
+     */
+    private Map dentry;
+
+    /**
+     * 钉盘: 下一页的游标,为空字符串则表示分页结束
+     */
+    private String nextToken;
+
+    /// 空间列表
+    private List<Map> spaces;
+
+    /// 文件或文件夹列表
+    private List<Map> dentries;
+
+    /// 知识库
+    private List<Map> workspaces;
+
+    private List<Map> items;
+
+    private Map workspace;
+
+    // 知识库 [创建文件/文件夹]
+    private String workspaceId;
+    private String dentryUuid;
+    private String nodeId;
+    private String url;
+
+    // 知识库 [复制文件/文件夹]
+    private String dentryId; // nodeId
+    private String spaceId;
+    private String name;
+
+    // 知识库 [节点信息]
+    private Map creator;
+    private Map updater;
+
+    List<Map> permissions; // 权限列表以及人员
+
+    ////  专属钉  ////
+
+    // 避免无数据返回空
+    private List<Map> list = new ArrayList<>();
+
+    // 成功状态标记 [氚云]
+    private final static String SUC_CODE = "success";
+
+    /**
+     * 断言错误信息
+     * -
+     * 新版本: 若存在code则失败, 否则直接返回请求字段, 无状态包装 [已冗余全部字段]
+     */
+    @Override
+    public void assertSuccess() {
+        if (ObjectUtil.isNotNull(accessdenieddetail)) {
+            message = "没有调用该接口的权限: " + accessdenieddetail;
+        }
+        McException.assertException(ObjectUtil.isNotNull(code) && !SUC_CODE.equals(code), code, message, "dingtalk_new");
+    }
+
+    /**
+     * 通用post请求
+     */
+    public static DDR_New doPost(String url, Map header, Map param, Map body) {
+        return (DDR_New) DDR.doPost(url, header, param, body, VenR.RC_DD_New);
+    }
+
+    /**
+     * 通用get请求
+     */
+    public static DDR_New doGet(String url, Map header, Map param) {
+        return (DDR_New) DDR.doGet(url, header, param, VenR.RC_DD_New);
+    }
+}

+ 404 - 0
mjava/src/main/java/com/malk/server/dingtalk/crypto/DingCallbackCrypto.java

@@ -0,0 +1,404 @@
+package com.malk.server.dingtalk.crypto;
+
+import com.google.common.io.BaseEncoding;
+import org.apache.commons.codec.binary.Base64;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Field;
+import java.nio.charset.Charset;
+import java.security.MessageDigest;
+import java.security.Permission;
+import java.security.PermissionCollection;
+import java.security.Security;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Random;
+
+/**
+ * 钉钉开放平台加解密方法
+ * 在ORACLE官方网站下载JCE无限制权限策略文件
+ * JDK6的下载地址:http://www.oracle.com/technetwork/java/javase/downloads/jce-6-download-429243.html
+ * JDK7的下载地址: http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html
+ * JDK8的下载地址 https://www.oracle.com/java/technologies/javase-jce8-downloads.html
+ */
+public class DingCallbackCrypto {
+
+    private static final Charset CHARSET = Charset.forName("utf-8");
+    private static final Base64 base64 = new Base64();
+    private byte[] aesKey;
+    private String token;
+    private String corpId;
+    /**
+     * ask getPaddingBytes key固定长度
+     **/
+    private static final Integer AES_ENCODE_KEY_LENGTH = 43;
+    /**
+     * 加密随机字符串字节长度
+     **/
+    private static final Integer RANDOM_LENGTH = 16;
+
+    /**
+     * 构造函数
+     *
+     * @param token          钉钉开放平台上,开发者设置的token
+     * @param encodingAesKey 钉钉开放台上,开发者设置的EncodingAESKey
+     * @param corpId         企业自建应用-事件订阅, 使用appKey
+     *                       企业自建应用-注册回调地址, 使用corpId
+     *                       第三方企业应用, 使用suiteKey
+     * @throws DingTalkEncryptException 执行失败,请查看该异常的错误码和具体的错误信息
+     */
+    public DingCallbackCrypto(String token, String encodingAesKey, String corpId) throws DingTalkEncryptException {
+        if (null == encodingAesKey || encodingAesKey.length() != AES_ENCODE_KEY_LENGTH) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.AES_KEY_ILLEGAL);
+        }
+        this.token = token;
+        this.corpId = corpId;
+        // ppExt: 23.10.26 钉钉新方式以Steam接入, HTTP形式commonsc-codec在升级之后,其内部做了一个validateCharacter校验. 使用 guava 替代
+        aesKey = BaseEncoding.base64().decode(encodingAesKey + "=");
+    }
+
+    public Map<String, String> getEncryptedMap(String plaintext) throws DingTalkEncryptException {
+        return getEncryptedMap(plaintext, System.currentTimeMillis(), Utils.getRandomStr(16));
+    }
+
+    /**
+     * 将和钉钉开放平台同步的消息体加密,返回加密Map
+     *
+     * @param plaintext 传递的消息体明文
+     * @param timeStamp 时间戳
+     * @param nonce     随机字符串
+     * @return
+     * @throws DingTalkEncryptException
+     */
+    public Map<String, String> getEncryptedMap(String plaintext, Long timeStamp, String nonce)
+            throws DingTalkEncryptException {
+        if (null == plaintext) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_PLAINTEXT_ILLEGAL);
+        }
+        if (null == timeStamp) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_TIMESTAMP_ILLEGAL);
+        }
+        if (null == nonce) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_NONCE_ILLEGAL);
+        }
+        // 加密
+        String encrypt = encrypt(Utils.getRandomStr(RANDOM_LENGTH), plaintext);
+        String signature = getSignature(token, String.valueOf(timeStamp), nonce, encrypt);
+        Map<String, String> resultMap = new HashMap<String, String>();
+        resultMap.put("msg_signature", signature);
+        resultMap.put("encrypt", encrypt);
+        resultMap.put("timeStamp", String.valueOf(timeStamp));
+        resultMap.put("nonce", nonce);
+        return resultMap;
+    }
+
+    /**
+     * 密文解密
+     *
+     * @param msgSignature 签名串
+     * @param timeStamp    时间戳
+     * @param nonce        随机串
+     * @param encryptMsg   密文
+     * @return 解密后的原文
+     * @throws DingTalkEncryptException
+     */
+    public String getDecryptMsg(String msgSignature, String timeStamp, String nonce, String encryptMsg)
+            throws DingTalkEncryptException {
+        //校验签名
+        String signature = getSignature(token, timeStamp, nonce, encryptMsg);
+        if (!signature.equals(msgSignature)) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_SIGNATURE_ERROR);
+        }
+        // 解密
+        String result = decrypt(encryptMsg);
+        return result;
+    }
+
+    /*
+     * 对明文加密.
+     * @param text 需要加密的明文
+     * @return 加密后base64编码的字符串
+     */
+    private String encrypt(String random, String plaintext) throws DingTalkEncryptException {
+        try {
+            byte[] randomBytes = random.getBytes(CHARSET);
+            byte[] plainTextBytes = plaintext.getBytes(CHARSET);
+            byte[] lengthByte = Utils.int2Bytes(plainTextBytes.length);
+            byte[] corpidBytes = corpId.getBytes(CHARSET);
+            ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
+            byteStream.write(randomBytes);
+            byteStream.write(lengthByte);
+            byteStream.write(plainTextBytes);
+            byteStream.write(corpidBytes);
+            byte[] padBytes = PKCS7Padding.getPaddingBytes(byteStream.size());
+            byteStream.write(padBytes);
+            byte[] unencrypted = byteStream.toByteArray();
+            byteStream.close();
+            Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
+            SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
+            IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16);
+            cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);
+            byte[] encrypted = cipher.doFinal(unencrypted);
+            String result = base64.encodeToString(encrypted);
+            return result;
+        } catch (Exception e) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_ENCRYPT_TEXT_ERROR);
+        }
+    }
+
+    /*
+     * 对密文进行解密.
+     * @param text 需要解密的密文
+     * @return 解密得到的明文
+     */
+    private String decrypt(String text) throws DingTalkEncryptException {
+        byte[] originalArr;
+        try {
+            // 设置解密模式为AES的CBC模式
+            Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
+            SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
+            IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
+            cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);
+            // 使用BASE64对密文进行解码
+            byte[] encrypted = Base64.decodeBase64(text);
+            // 解密
+            originalArr = cipher.doFinal(encrypted);
+        } catch (Exception e) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_ERROR);
+        }
+
+        String plainText;
+        String fromCorpid;
+        try {
+            // 去除补位字符
+            byte[] bytes = PKCS7Padding.removePaddingBytes(originalArr);
+            // 分离16位随机字符串,网络字节序和corpId
+            byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
+            int plainTextLegth = Utils.bytes2int(networkOrder);
+            plainText = new String(Arrays.copyOfRange(bytes, 20, 20 + plainTextLegth), CHARSET);
+            fromCorpid = new String(Arrays.copyOfRange(bytes, 20 + plainTextLegth, bytes.length), CHARSET);
+        } catch (Exception e) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_LENGTH_ERROR);
+        }
+
+        // corpid不相同的情况
+        if (!fromCorpid.equals(corpId)) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_CORPID_ERROR);
+        }
+        return plainText;
+    }
+
+    /**
+     * 数字签名
+     *
+     * @param token     isv token
+     * @param timestamp 时间戳
+     * @param nonce     随机串
+     * @param encrypt   加密文本
+     * @return
+     * @throws DingTalkEncryptException
+     */
+    public String getSignature(String token, String timestamp, String nonce, String encrypt)
+            throws DingTalkEncryptException {
+        try {
+            String[] array = new String[]{token, timestamp, nonce, encrypt};
+            Arrays.sort(array);
+//            System.out.println(JSON.toJSONString(array));
+            StringBuffer sb = new StringBuffer();
+            for (int i = 0; i < 4; i++) {
+                sb.append(array[i]);
+            }
+            String str = sb.toString();
+//            System.out.println(str);
+            MessageDigest md = MessageDigest.getInstance("SHA-1");
+            md.update(str.getBytes());
+            byte[] digest = md.digest();
+
+            StringBuffer hexstr = new StringBuffer();
+            String shaHex = "";
+            for (int i = 0; i < digest.length; i++) {
+                shaHex = Integer.toHexString(digest[i] & 0xFF);
+                if (shaHex.length() < 2) {
+                    hexstr.append(0);
+                }
+                hexstr.append(shaHex);
+            }
+            return hexstr.toString();
+        } catch (Exception e) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_SIGNATURE_ERROR);
+        }
+    }
+
+    public static class Utils {
+        public Utils() {
+        }
+
+        public static String getRandomStr(int count) {
+            String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+            Random random = new Random();
+            StringBuffer sb = new StringBuffer();
+
+            for (int i = 0; i < count; ++i) {
+                int number = random.nextInt(base.length());
+                sb.append(base.charAt(number));
+            }
+
+            return sb.toString();
+        }
+
+        public static byte[] int2Bytes(int count) {
+            byte[] byteArr = new byte[]{(byte) (count >> 24 & 255), (byte) (count >> 16 & 255), (byte) (count >> 8 & 255),
+                    (byte) (count & 255)};
+            return byteArr;
+        }
+
+        public static int bytes2int(byte[] byteArr) {
+            int count = 0;
+
+            for (int i = 0; i < 4; ++i) {
+                count <<= 8;
+                count |= byteArr[i] & 255;
+            }
+
+            return count;
+        }
+    }
+
+    public static class PKCS7Padding {
+        private static final Charset CHARSET = Charset.forName("utf-8");
+        private static final int BLOCK_SIZE = 32;
+
+        public PKCS7Padding() {
+        }
+
+        public static byte[] getPaddingBytes(int count) {
+            int amountToPad = 32 - count % 32;
+            if (amountToPad == 0) {
+                amountToPad = 32;
+            }
+
+            char padChr = chr(amountToPad);
+            String tmp = new String();
+
+            for (int index = 0; index < amountToPad; ++index) {
+                tmp = tmp + padChr;
+            }
+
+            return tmp.getBytes(CHARSET);
+        }
+
+        public static byte[] removePaddingBytes(byte[] decrypted) {
+            int pad = decrypted[decrypted.length - 1];
+            if (pad < 1 || pad > 32) {
+                pad = 0;
+            }
+
+            return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
+        }
+
+        private static char chr(int a) {
+            byte target = (byte) (a & 255);
+            return (char) target;
+        }
+    }
+
+    public static class DingTalkEncryptException extends Exception {
+        public static final int SUCCESS = 0;
+        public static final int ENCRYPTION_PLAINTEXT_ILLEGAL = 900001;
+        public static final int ENCRYPTION_TIMESTAMP_ILLEGAL = 900002;
+        public static final int ENCRYPTION_NONCE_ILLEGAL = 900003;
+        public static final int AES_KEY_ILLEGAL = 900004;
+        public static final int SIGNATURE_NOT_MATCH = 900005;
+        public static final int COMPUTE_SIGNATURE_ERROR = 900006;
+        public static final int COMPUTE_ENCRYPT_TEXT_ERROR = 900007;
+        public static final int COMPUTE_DECRYPT_TEXT_ERROR = 900008;
+        public static final int COMPUTE_DECRYPT_TEXT_LENGTH_ERROR = 900009;
+        public static final int COMPUTE_DECRYPT_TEXT_CORPID_ERROR = 900010;
+        private static final long serialVersionUID = 4139585721260116198L;
+        private static Map<Integer, String> msgMap = new HashMap();
+        private Integer code;
+
+        static {
+            msgMap.put(0, "成功");
+            msgMap.put(900001, "加密明文文本非法");
+            msgMap.put(900002, "加密时间戳参数非法");
+            msgMap.put(900003, "加密随机字符串参数非法");
+            msgMap.put(900005, "签名不匹配");
+            msgMap.put(900006, "签名计算失败");
+            msgMap.put(900004, "不合法的aes key");
+            msgMap.put(900007, "计算加密文字错误");
+            msgMap.put(900008, "计算解密文字错误");
+            msgMap.put(900009, "计算解密文字长度不匹配");
+            msgMap.put(900010, "计算解密文字corpid不匹配");
+        }
+
+        public Integer getCode() {
+            return this.code;
+        }
+
+        public DingTalkEncryptException(Integer exceptionCode) {
+            super((String) msgMap.get(exceptionCode));
+            this.code = exceptionCode;
+        }
+    }
+
+    static {
+        try {
+            Security.setProperty("crypto.policy", "limited");
+            RemoveCryptographyRestrictions();
+        } catch (Exception var1) {
+        }
+
+    }
+
+    private static void RemoveCryptographyRestrictions() throws Exception {
+        Class<?> jceSecurity = getClazz("javax.crypto.JceSecurity");
+        Class<?> cryptoPermissions = getClazz("javax.crypto.CryptoPermissions");
+        Class<?> cryptoAllPermission = getClazz("javax.crypto.CryptoAllPermission");
+        if (jceSecurity != null) {
+            setFinalStaticValue(jceSecurity, "isRestricted", false);
+            PermissionCollection defaultPolicy = (PermissionCollection) getFieldValue(jceSecurity, "defaultPolicy", (Object) null, PermissionCollection.class);
+            if (cryptoPermissions != null) {
+                Map<?, ?> map = (Map) getFieldValue(cryptoPermissions, "perms", defaultPolicy, Map.class);
+                map.clear();
+            }
+
+            if (cryptoAllPermission != null) {
+                Permission permission = (Permission) getFieldValue(cryptoAllPermission, "INSTANCE", (Object) null, Permission.class);
+                defaultPolicy.add(permission);
+            }
+        }
+
+    }
+
+    private static Class<?> getClazz(String className) {
+        Class clazz = null;
+
+        try {
+            clazz = Class.forName(className);
+        } catch (Exception var3) {
+        }
+
+        return clazz;
+    }
+
+    private static void setFinalStaticValue(Class<?> srcClazz, String fieldName, Object newValue) throws Exception {
+        Field field = srcClazz.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        Field modifiersField = Field.class.getDeclaredField("modifiers");
+        modifiersField.setAccessible(true);
+        modifiersField.setInt(field, field.getModifiers() & -17);
+        field.set((Object) null, newValue);
+    }
+
+    private static <T> T getFieldValue(Class<?> srcClazz, String fieldName, Object owner, Class<T> dstClazz) throws Exception {
+        Field field = srcClazz.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        return dstClazz.cast(field.get(owner));
+    }
+
+}

+ 42 - 0
mjava/src/main/java/com/malk/server/ekuaibao/EKBConf.java

@@ -0,0 +1,42 @@
+package com.malk.server.ekuaibao;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.utils.UtilHttp;
+import lombok.Data;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 读取配置文件参考FilePah
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "ekuaibao")
+public class EKBConf {
+
+    // 易快报 corpId
+    private String corpId;
+
+    // 易快报不同平台获取前缀的方式不同
+    private String platformApi;
+
+    private String appKey;
+
+    private String appSecurity;
+
+    /**
+     * 获取地址前缀
+     */
+    private static String platformUrl;
+
+    public String getPlatformUrl() {
+        if (StringUtils.isBlank(platformUrl)) {
+            String rsp = UtilHttp.doGet(platformApi + "/api/openapi/v2/location?corpId=" + corpId, null, (Map) null);
+            platformUrl = String.valueOf(((Map) JSON.parse(rsp)).get("value"));
+        }
+        return platformUrl;
+    }
+}

+ 56 - 0
mjava/src/main/java/com/malk/server/ekuaibao/EKBR.java

@@ -0,0 +1,56 @@
+package com.malk.server.ekuaibao;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 返回值配置参考McR
+ */
+@Data
+@NoArgsConstructor
+public class EKBR extends VenR {
+
+    private String errorCode;
+
+    private String errorMessage;
+
+    /**
+     * 返回数据
+     */
+    private List<Map> items;
+
+    /**
+     * 单据详情
+     */
+    private Map value;
+
+    /**
+     * 创建单据
+     */
+    Map flow;
+
+    int type;
+
+    /**
+     * 单据数量
+     */
+    long count;
+
+    // 成功状态标记
+    private final static String SUC_CODE = "0";
+
+    /**
+     * 断言错误信息
+     */
+    @Override
+    public void assertSuccess() {
+        McException.assertException(ObjectUtil.isNotNull(errorCode) && !errorCode.equals(SUC_CODE), errorCode, errorMessage, "ekuaibao");
+    }
+}
+

+ 24 - 0
mjava/src/main/java/com/malk/server/fxiaoke/FXKConf.java

@@ -0,0 +1,24 @@
+package com.malk.server.fxiaoke;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 读取配置文件参考FilePah
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "fxiaoke")
+public class FXKConf {
+
+    /// 纷享销客 corpId
+    private String corpId;
+
+    private String appId;
+
+    private String appSecret;
+
+    /// 永久授权码
+    private String permanentCode;
+}

+ 67 - 0
mjava/src/main/java/com/malk/server/fxiaoke/FXXR.java

@@ -0,0 +1,67 @@
+package com.malk.server.fxiaoke;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.utils.UtilHttp;
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 返回值配置参考McR
+ */
+@Data
+@NoArgsConstructor
+public class FXXR extends VenR {
+
+    private String errorCode;
+
+    private String errorMessage;
+
+    private String errorDescription;
+
+    /**
+     * token 接口
+     */
+    private String corpAccessToken;
+    private int expiresIn;
+
+    /**
+     * 员工信息
+     */
+    private List<Map> empList;
+
+    /**
+     * 外勤数据
+     */
+    private List<Map> datas;
+
+    // 成功状态标记
+    private final static String SUC_CODE = "0";
+
+    /**
+     * 断言错误信息
+     */
+    @Override
+    public void assertSuccess() {
+        McException.assertException(ObjectUtil.isNotNull(errorCode) && !errorCode.equals(SUC_CODE), errorCode, errorDescription, "fxiaoke");
+    }
+
+    /**
+     * 通用post请求
+     */
+    public static FXXR doPost(String url, Map header, Map param, Map body) {
+        return (FXXR) UtilHttp.doPost(url, header, param, body, FXXR.class);
+    }
+
+    /**
+     * 通用get请求
+     */
+    public static FXXR doGet(String url, Map header, Map param) {
+        return (FXXR) UtilHttp.doGet(url, header, param, FXXR.class);
+    }
+}
+

+ 59 - 0
mjava/src/main/java/com/malk/server/h3yun/CYConf.java

@@ -0,0 +1,59 @@
+package com.malk.server.h3yun;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "h3yun")
+@Slf4j
+public class CYConf {
+
+    private String engineCode;
+
+    private String secret;
+    
+    /////////////////////////////// 静态方法 ///////////////////////////////
+
+    // 按钮动作
+    public enum ACTION_NAME {
+
+        Reject("Reject"),                   // 拒绝
+        Save("Save"),                       // 暂存
+        Forward("Forward"),                 // 转交
+        Remove("Remove"),                   // 删除
+        Submit("Submit"),                   // 提交/同意 [区分是否审批节点]
+        ForComment("ForComment"),           // 前加签
+        TempAddition("TempAddition"),       // 后加签
+        CancelInstance("CancelInstance");    // 作废 [退回不触发, 退回至发起节点, 可激活作废按钮]
+
+        @Getter
+        private String desc;
+
+        ACTION_NAME(String desc) {
+            this.desc = desc;
+        }
+    }
+
+    public static boolean isSubmit(String actionName) {
+        return actionName.equals(ACTION_NAME.Submit.desc);
+    }
+
+    public static boolean isReject(String actionName) {
+        return actionName.equals(ACTION_NAME.Reject.desc);
+    }
+
+    public static boolean isRemove(String actionName) {
+        return actionName.equals(ACTION_NAME.Remove.desc);
+    }
+
+    /**
+     * 获取原附件预览地址 [只能作为在线预览, 接口传递无登录态]
+     */
+    public static final String getAttachmentPreviewUrl(String attachmentId) {
+        return "https://www.h3yun.com/Form/DoPreview/?attachmentId=" + attachmentId;
+    }
+}

+ 92 - 0
mjava/src/main/java/com/malk/server/teambition/TBConf.java

@@ -0,0 +1,92 @@
+package com.malk.server.teambition;
+
+import com.malk.utils.UtilMap;
+import lombok.Data;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+
+/**
+ * 读取配置文件参考FilePah
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "teambition")
+public class TBConf {
+
+    private String AppID;
+
+    private String AppSecret;
+
+    private String TenantId;
+
+    private String OperatorId;
+
+    private String ApiHost;
+
+    public String getApiHost() {
+        if (StringUtils.isNotBlank(ApiHost)) {
+            return ApiHost;
+        }
+        return "https://open.teambition.com/api"; // 公有云环境
+    }
+
+    /**
+     * 一个分页数量上限 [上限 1000]
+     */
+    public static final Integer PAGE_SIZE_LIMIT = 1000;
+
+    /**
+     * 租户类型: 默认是组织
+     */
+    public static final String TENANT_TYPE = "organization";
+
+    /**
+     * 推送事件验证
+     */
+    public static final String EVENT_VERIFY_HOOK = "VERIFY_HOOK";
+
+    /// 获取任务字段值
+    public static List<Map> getTaskFieldValue(List<Map> customfields, String fieldId) {
+
+        Optional optional = customfields.stream().filter(item -> fieldId.equals(item.get("cfId"))).findAny();
+        if (optional.isPresent()) {
+            return UtilMap.getList((Map) optional.get(), "value");
+        }
+        return new ArrayList<>();
+    }
+
+    /// 获取任务字段集合第一个值
+    public static String getTaskFieldValue_First(List<Map> customfields, String fieldId) {
+
+        List<Map> value = getTaskFieldValue(customfields, fieldId);
+        if (value.size() > 0) {
+            return String.valueOf(value.get(0).get("title"));
+        }
+        return "";
+    }
+
+    /// 更新任务自定义字段值 [ppExt: 富文本不能解析, 知识库可写入] - todo 不支持多参, 参考知识库版本管理更新
+    public static Map assembleCustomField(String fieldName, String fieldValue, String value, Object meta) {
+        Map body = UtilMap.map(fieldName, fieldValue);
+        Map data = UtilMap.map("title", (Object) value);
+        UtilMap.putNotNull(data, "meta", meta);
+        body.put("value", Arrays.asList(data));
+        return body;
+    }
+
+    public static Map assembleCustomFieldName(String fieldValue, String value) {
+        return assembleCustomField("customfieldName", fieldValue, value, null);
+    }
+
+    public static Map assembleCustomFieldId(String fieldValue, String value) {
+        return assembleCustomField("customfieldId", fieldValue, value, null);
+    }
+
+    // 数据清空
+    public static Map assembleCustomFieldName(String fieldValue) {
+        return UtilMap.map("customfieldName, value", fieldValue,Arrays.asList());
+    }
+}

+ 38 - 0
mjava/src/main/java/com/malk/server/teambition/TBR.java

@@ -0,0 +1,38 @@
+package com.malk.server.teambition;
+
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+public class TBR<T> extends VenR {
+
+    private String code;
+
+    private String errorMessage;
+
+    /**
+     * 接口返回数据
+     */
+    private T result;
+
+    // 成功状态标记
+    private final static String SUC_CODE = "200";
+
+    // ppExt: 204为重复修改或tb设置了不允许修改规则
+    private final static String NOR_CODE = "204";
+
+    private String nextPageToken;
+
+    private long totalSize;
+
+    /**
+     * 断言错误信息
+     */
+    @Override
+    public void assertSuccess() {
+        McException.assertException(!(code.equals(SUC_CODE) || code.equals(NOR_CODE)), code, errorMessage, "TB");
+    }
+}

+ 26 - 0
mjava/src/main/java/com/malk/server/vika/VKConf.java

@@ -0,0 +1,26 @@
+package com.malk.server.vika;
+
+import com.malk.utils.UtilMap;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 读取配置文件参考FilePah
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "vika")
+public class VKConf {
+
+    private String apiToken;
+
+    /**
+     * api token授权参数
+     */
+    public Map initTokenHeader() {
+        return UtilMap.map("Authorization", "Bearer " + apiToken);
+    }
+}

+ 34 - 0
mjava/src/main/java/com/malk/server/vika/VKR.java

@@ -0,0 +1,34 @@
+package com.malk.server.vika;
+
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 返回值配置参考McR
+ */
+@Data
+@NoArgsConstructor
+public class VKR<T> extends VenR {
+
+
+    private int code;
+
+    private String message;
+
+    private boolean success;
+
+    private T data;
+
+    // 成功状态标记
+    private final static int SUC_CODE = 200;
+
+    /**
+     * 断言错误信息
+     */
+    @Override
+    public void assertSuccess() {
+        McException.assertException(code != SUC_CODE || !isSuccess(), String.valueOf(code), message, "vika");
+    }
+}

+ 0 - 0
mjava/src/main/java/com/malk/server/xbongbong/DigestUtil.java


Некоторые файлы не были показаны из-за большого количества измененных файлов