move claude-marketplace to ai-proj-helper
This commit is contained in:
16
PUSH.sh
Executable file
16
PUSH.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
echo "🚀 Pushing to Gitea..."
|
||||||
|
echo ""
|
||||||
|
echo "Make sure you've created the repository on Gitea:"
|
||||||
|
echo "https://gitea.pipexerp.com/huangjun/claude-marketplace"
|
||||||
|
echo ""
|
||||||
|
read -p "Press Enter to continue..."
|
||||||
|
|
||||||
|
git push -u origin main
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Done! Your marketplace is now live at:"
|
||||||
|
echo "https://gitea.pipexerp.com/huangjun/claude-marketplace"
|
||||||
|
echo ""
|
||||||
|
echo "Test it with:"
|
||||||
|
echo "/plugin marketplace add git@gitea.pipexerp.com:huangjun/claude-marketplace.git"
|
||||||
179
README.md
179
README.md
@@ -0,0 +1,179 @@
|
|||||||
|
# Claude Code Plugin Marketplace
|
||||||
|
|
||||||
|
Custom Claude Code plugins for development workflows, DevOps, and business operations.
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Add the Marketplace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin marketplace add git@gitea.pipexerp.com:huangjun/claude-marketplace.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using HTTPS (requires credential configuration):
|
||||||
|
```bash
|
||||||
|
/plugin marketplace add https://gitea.pipexerp.com/huangjun/claude-marketplace.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install Plugins
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install specific plugin
|
||||||
|
/plugin install ai-proj-plugin@coolbuy-claude-plugins
|
||||||
|
|
||||||
|
# List all available plugins
|
||||||
|
/plugin marketplace list coolbuy-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Available Plugins (40 Total)
|
||||||
|
|
||||||
|
### 🚀 Development & Coding
|
||||||
|
- **ai-proj-plugin** - AI project management with MCP integration
|
||||||
|
- **dev-plugin** - General development workflows
|
||||||
|
- **dev-arch-plugin** - Software architecture and design
|
||||||
|
- **dev-coding-plugin** - Coding standards and best practices
|
||||||
|
- **dev-test-plugin** - Testing workflows and automation
|
||||||
|
- **frontend-design-plugin** - Frontend design and UI/UX
|
||||||
|
|
||||||
|
### 🔧 DevOps & Operations
|
||||||
|
- **ops-tools-plugin** - DevOps tools for deployment and monitoring
|
||||||
|
- **ops-servers-plugin** - Server management and SSH operations
|
||||||
|
- **enjoysa-deploy-plugin** - Enjoysa deployment automation
|
||||||
|
- **req-deploy-plugin** - Requirement deployment workflows
|
||||||
|
|
||||||
|
### 📋 Project & Requirements Management
|
||||||
|
- **req-plugin** - Requirement management workflows
|
||||||
|
- **req-dev-plugin** - Requirement development processes
|
||||||
|
- **req-prd-plugin** - PRD (Product Requirements Document) creation
|
||||||
|
- **req-commands-plugin** - Detailed command reference for /req
|
||||||
|
- **req-review-plugin** - PRD review methodology
|
||||||
|
- **req-workflow-plugin** - Complete requirement lifecycle workflow
|
||||||
|
- **requirement-plugin** - General requirement handling
|
||||||
|
- **executing-plans-plugin** - Plan execution and tracking
|
||||||
|
|
||||||
|
### 💼 Business Operations
|
||||||
|
- **biz-plan-plugin** - Business planning and proposals
|
||||||
|
- **biz-contract-plugin** - Contract drafting and management
|
||||||
|
- **finance-plugin** - Financial operations and reporting
|
||||||
|
|
||||||
|
### 🔗 Integrations
|
||||||
|
- **feishu-plugin** - Feishu/Lark integration (documents, spreadsheets)
|
||||||
|
- **feishu-bitable-plugin** - Feishu Bitable (multi-dimensional tables)
|
||||||
|
- **feishu-docx-plugin** - Feishu cloud documents
|
||||||
|
- **wecom-plugin** - WeChat Work integration
|
||||||
|
- **siyuan-plugin** - SiYuan note-taking integration
|
||||||
|
- **data-excel-plugin** - Excel data processing
|
||||||
|
- **doubao-voice-plugin** - Doubao Voice API (TTS, ASR, dialogue)
|
||||||
|
|
||||||
|
### 🏢 Project-Specific
|
||||||
|
- **coolbuy-legacy-plugin** - Coolbuy legacy system workflows
|
||||||
|
- **coolbuy-paas-plugin** - Coolbuy PaaS platform operations
|
||||||
|
- **coolbuy-platform-plugin** - Coolbuy platform development
|
||||||
|
- **enjoysa-plugin** - Enjoysa project workflows
|
||||||
|
|
||||||
|
### 🔄 Session Management
|
||||||
|
- **save-session-plugin** - Save Claude Code sessions
|
||||||
|
- **reload-session-plugin** - Reload saved sessions
|
||||||
|
- **read-session-plugin** - Read session data
|
||||||
|
- **search-sessions-plugin** - Search through sessions
|
||||||
|
|
||||||
|
### 🛠️ Utilities
|
||||||
|
- **pr-plugin** - Pull request creation and management
|
||||||
|
- **finishing-a-development-branch-plugin** - Branch finishing workflows
|
||||||
|
- **skill-manager-plugin** - Skill management utilities
|
||||||
|
- **qiudl-personal-plugin** - Personal productivity tools
|
||||||
|
|
||||||
|
## 📖 Usage Examples
|
||||||
|
|
||||||
|
### AI Project Management
|
||||||
|
```bash
|
||||||
|
/ai-proj-plugin:create-task "Implement user authentication"
|
||||||
|
/ai-proj-plugin:start-timer
|
||||||
|
```
|
||||||
|
|
||||||
|
### DevOps Operations
|
||||||
|
```bash
|
||||||
|
/ops-tools-plugin:deploy production
|
||||||
|
/ops-servers-plugin:check-status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
```bash
|
||||||
|
/dev-coding-plugin:start-feature "user-profile"
|
||||||
|
/finishing-a-development-branch-plugin:create-pr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Business Operations
|
||||||
|
```bash
|
||||||
|
/biz-contract-plugin:draft "Software Subscription Agreement"
|
||||||
|
/biz-plan-plugin:create-proposal
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Private Repository Access
|
||||||
|
|
||||||
|
For background auto-updates, set authentication token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to ~/.zshrc or ~/.bashrc
|
||||||
|
export GITEA_TOKEN="your-gitea-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Plugin Naming Convention
|
||||||
|
|
||||||
|
All plugins use namespaced commands to avoid conflicts:
|
||||||
|
- Format: `/plugin-name:command-name`
|
||||||
|
- Example: `/ai-proj-plugin:create-task`
|
||||||
|
|
||||||
|
## 🔄 Updates
|
||||||
|
|
||||||
|
Update the marketplace and plugins:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update marketplace catalog
|
||||||
|
/plugin marketplace update coolbuy-claude-plugins
|
||||||
|
|
||||||
|
# Update specific plugin
|
||||||
|
/plugin update ai-proj-plugin@coolbuy-claude-plugins
|
||||||
|
|
||||||
|
# Update all plugins from this marketplace
|
||||||
|
/plugin update-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Plugin Structure
|
||||||
|
|
||||||
|
Each plugin follows the official Claude Code plugin structure:
|
||||||
|
```
|
||||||
|
plugin-name-plugin/
|
||||||
|
├── .claude-plugin/
|
||||||
|
│ └── plugin.json # Plugin metadata
|
||||||
|
├── skills/
|
||||||
|
│ └── SKILL.md # Agent skills
|
||||||
|
└── [scripts, configs, etc.]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
- [Official Claude Code Plugin Docs](https://code.claude.com/docs/en/plugins)
|
||||||
|
- [Plugin Marketplace Guide](https://code.claude.com/docs/en/plugin-marketplaces)
|
||||||
|
|
||||||
|
## 👤 Author
|
||||||
|
|
||||||
|
**Donglin Lai (qiudl)**
|
||||||
|
- Email: qiudl@zhiyuncai.com
|
||||||
|
- Organization: PipeX ERP
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Individual plugins may have their own licenses. Please check each plugin's documentation.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
To add or update plugins:
|
||||||
|
1. Clone this repository
|
||||||
|
2. Add/modify plugins in the `plugins/` directory
|
||||||
|
3. Regenerate `marketplace.json` using the provided scripts
|
||||||
|
4. Submit changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Repository**: git@gitea.pipexerp.com:huangjun/claude-marketplace.git
|
||||||
|
|||||||
129
SETUP.md
Normal file
129
SETUP.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Setup Guide
|
||||||
|
|
||||||
|
## 1. Create Repository on Gitea
|
||||||
|
|
||||||
|
Go to https://gitea.pipexerp.com and create a new repository:
|
||||||
|
- Name: `claude-marketplace`
|
||||||
|
- Visibility: Private or Public (your choice)
|
||||||
|
- **Do NOT** initialize with README (we already have one)
|
||||||
|
|
||||||
|
## 2. Push to Gitea
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/junhuang/coolbuy/claude-marketplace
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Test Installation
|
||||||
|
|
||||||
|
### Add the marketplace
|
||||||
|
```bash
|
||||||
|
# SSH (recommended)
|
||||||
|
/plugin marketplace add git@gitea.pipexerp.com:huangjun/claude-marketplace.git
|
||||||
|
|
||||||
|
# OR HTTPS (requires credential configuration)
|
||||||
|
/plugin marketplace add https://gitea.pipexerp.com/huangjun/claude-marketplace.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### List available plugins
|
||||||
|
```bash
|
||||||
|
/plugin marketplace list coolbuy-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install a plugin
|
||||||
|
```bash
|
||||||
|
/plugin install ai-proj-plugin@coolbuy-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test the plugin
|
||||||
|
```bash
|
||||||
|
# Skills are auto-invoked by Claude when relevant, or use:
|
||||||
|
/help
|
||||||
|
# Check for your installed plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Update Plugins Later
|
||||||
|
|
||||||
|
When you make changes and push updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/junhuang/coolbuy/claude-marketplace
|
||||||
|
|
||||||
|
# Make changes to plugins
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Regenerate marketplace.json if needed
|
||||||
|
python3 generate-marketplace.py
|
||||||
|
|
||||||
|
# Commit and push
|
||||||
|
git add .
|
||||||
|
git commit -m "Update plugins"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Users update with:
|
||||||
|
```bash
|
||||||
|
/plugin marketplace update coolbuy-claude-plugins
|
||||||
|
/plugin update ai-proj-plugin@coolbuy-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Private Repository Setup
|
||||||
|
|
||||||
|
If your Gitea repo is private, users need authentication:
|
||||||
|
|
||||||
|
**For manual operations** (install, update):
|
||||||
|
- SSH: Configure SSH keys in Gitea
|
||||||
|
- HTTPS: Will prompt for credentials
|
||||||
|
|
||||||
|
**For background auto-updates**:
|
||||||
|
```bash
|
||||||
|
# Add to ~/.zshrc or ~/.bashrc
|
||||||
|
export GITEA_TOKEN="your-gitea-personal-access-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
To create a Gitea token:
|
||||||
|
1. Go to https://gitea.pipexerp.com/user/settings/applications
|
||||||
|
2. Generate New Token
|
||||||
|
3. Give it "Read repository" permissions
|
||||||
|
4. Copy the token and add to your environment
|
||||||
|
|
||||||
|
## 6. Structure Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-marketplace/
|
||||||
|
├── .claude-plugin/
|
||||||
|
│ └── marketplace.json # Catalog of all plugins
|
||||||
|
├── plugins/
|
||||||
|
│ ├── ai-proj-plugin/
|
||||||
|
│ │ ├── .claude-plugin/
|
||||||
|
│ │ │ └── plugin.json # Plugin metadata
|
||||||
|
│ │ └── skills/
|
||||||
|
│ │ └── SKILL.md # Skill definition
|
||||||
|
│ └── [33 more plugins...]
|
||||||
|
├── README.md # User documentation
|
||||||
|
├── SETUP.md # This file
|
||||||
|
└── convert-skills.sh # Conversion script (reference)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Push to Gitea: `git push -u origin main`
|
||||||
|
2. ✅ Test locally: `/plugin marketplace add <url>`
|
||||||
|
3. ✅ Install plugins: `/plugin install <name>@coolbuy-claude-plugins`
|
||||||
|
4. ✅ Share with team: Send them the repository URL
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"Failed to clone repository"**
|
||||||
|
- Check SSH key configuration: `ssh -T git@gitea.pipexerp.com -p 10022`
|
||||||
|
- Or use HTTPS with credentials
|
||||||
|
|
||||||
|
**"Plugin not found"**
|
||||||
|
- Verify marketplace added: `/plugin marketplace list`
|
||||||
|
- Check plugin name is correct
|
||||||
|
- Ensure marketplace.json is valid: `cat .claude-plugin/marketplace.json | jq`
|
||||||
|
|
||||||
|
**"Skills not working"**
|
||||||
|
- Skills are Agent Skills (auto-invoked by Claude when relevant)
|
||||||
|
- They don't create slash commands
|
||||||
|
- Check plugin installation: `/plugin list`
|
||||||
218
SYNC-GUIDE.md
Normal file
218
SYNC-GUIDE.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Skill Sync Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide explains how to keep your local skills (`~/.claude/skills/`) synchronized with the marketplace plugins.
|
||||||
|
|
||||||
|
## Quick Sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/claude-marketplace
|
||||||
|
./sync-skills.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. ✅ Compare local skills with marketplace plugins
|
||||||
|
2. ➕ Add new skills as plugins
|
||||||
|
3. 📝 Update changed skills
|
||||||
|
4. ✓ Skip unchanged plugins
|
||||||
|
|
||||||
|
## Sync Workflow
|
||||||
|
|
||||||
|
### 1. Edit Skills Locally
|
||||||
|
|
||||||
|
Work on your skills in `~/.claude/skills/`:
|
||||||
|
```bash
|
||||||
|
code ~/.claude/skills/my-skill/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run Sync Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/path/to/claude-marketplace
|
||||||
|
./sync-skills.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Review Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
git diff
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Commit & Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Update skill: description of changes"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Team Updates
|
||||||
|
|
||||||
|
Team members update with:
|
||||||
|
```bash
|
||||||
|
/plugin marketplace update coolbuy-claude-plugins
|
||||||
|
/plugin update <plugin-name>@coolbuy-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automated Sync (Optional)
|
||||||
|
|
||||||
|
### Git Hook (Pre-commit)
|
||||||
|
|
||||||
|
Auto-sync when committing changes to skills:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In your dotfiles/skills repo
|
||||||
|
cat > .git/hooks/pre-commit << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Auto-sync skills to marketplace
|
||||||
|
~/path/to/claude-marketplace/sync-skills.sh
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x .git/hooks/pre-commit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Job (Scheduled)
|
||||||
|
|
||||||
|
Sync daily at 9 AM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
|
||||||
|
# Add this line:
|
||||||
|
0 9 * * * cd ~/path/to/claude-marketplace && ./sync-skills.sh && git add . && git commit -m "Daily sync" && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Skill Splitting Guidelines
|
||||||
|
|
||||||
|
From `~/.claude/CLAUDE.md`:
|
||||||
|
|
||||||
|
- **Token Limit**: Single skill ≤ 10,000 tokens
|
||||||
|
- **Check Size**: `wc -w ~/.claude/skills/<skill>/SKILL.md`
|
||||||
|
- **When to Split**: If > 7,500 words (≈10,000 tokens)
|
||||||
|
|
||||||
|
### Split Strategy
|
||||||
|
|
||||||
|
When a skill grows too large:
|
||||||
|
|
||||||
|
1. **Entry Skill** - Overview + command routing (<100 lines)
|
||||||
|
- Example: `req/SKILL.md`
|
||||||
|
|
||||||
|
2. **Command Reference** - Detailed commands (<200 lines)
|
||||||
|
- Example: `req-commands/SKILL.md`
|
||||||
|
|
||||||
|
3. **Workflow Guide** - Complete processes (<200 lines)
|
||||||
|
- Example: `req-workflow/SKILL.md`
|
||||||
|
|
||||||
|
4. **Methodology** - Complex concepts (<150 lines)
|
||||||
|
- Example: `req-review/SKILL.md`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Sync Script Fails
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check permissions
|
||||||
|
ls -la sync-skills.sh
|
||||||
|
|
||||||
|
# Make executable
|
||||||
|
chmod +x sync-skills.sh
|
||||||
|
|
||||||
|
# Check paths
|
||||||
|
echo $HOME/.claude/skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### marketplace.json Not Updated
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manually regenerate
|
||||||
|
python3 generate-marketplace.py
|
||||||
|
|
||||||
|
# Or edit directly
|
||||||
|
code .claude-plugin/marketplace.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git Conflicts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Discard local changes
|
||||||
|
git checkout .claude-plugin/marketplace.json
|
||||||
|
|
||||||
|
# Or merge manually
|
||||||
|
git mergetool
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Descriptive Frontmatter
|
||||||
|
|
||||||
|
Always include in `SKILL.md`:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: skill-name
|
||||||
|
description: Clear, concise description of what this skill does
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Version Bumping
|
||||||
|
|
||||||
|
When making significant changes:
|
||||||
|
```bash
|
||||||
|
# Update version in plugin.json
|
||||||
|
{
|
||||||
|
"version": "1.1.0" # was 1.0.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Testing Before Sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test skill locally first
|
||||||
|
/skill-name
|
||||||
|
|
||||||
|
# Then sync to marketplace
|
||||||
|
./sync-skills.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Commit Messages
|
||||||
|
|
||||||
|
Use clear, descriptive messages:
|
||||||
|
```bash
|
||||||
|
git commit -m "Add feishu-bitable plugin for table operations"
|
||||||
|
git commit -m "Update req-workflow with new approval process"
|
||||||
|
git commit -m "Fix: Correct PRD template in req-prd"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Check Sync Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compare local vs marketplace
|
||||||
|
diff -qr ~/.claude/skills /tmp/claude-marketplace/plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Differences
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find skills not in marketplace
|
||||||
|
comm -23 <(ls ~/.claude/skills | sort) <(ls plugins | sed 's/-plugin$//' | sort)
|
||||||
|
|
||||||
|
# Find plugins not in local
|
||||||
|
comm -13 <(ls ~/.claude/skills | sort) <(ls plugins | sed 's/-plugin$//' | sort)
|
||||||
|
```
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Can I sync in reverse (marketplace → local)?**
|
||||||
|
A: Not recommended. Treat local skills as the source of truth.
|
||||||
|
|
||||||
|
**Q: What about binary files (images, scripts)?**
|
||||||
|
A: Copy them manually to the plugin directory, then commit.
|
||||||
|
|
||||||
|
**Q: How do I remove a plugin?**
|
||||||
|
A: Delete the plugin directory, regenerate marketplace.json, commit, and push.
|
||||||
|
|
||||||
|
**Q: Can I sync specific skills only?**
|
||||||
|
A: Modify `sync-skills.sh` to accept a skill name parameter.
|
||||||
82
convert-skills.sh
Executable file
82
convert-skills.sh
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Convert dotfiles custom skills to official Claude Code plugins
|
||||||
|
|
||||||
|
SOURCE_DIR="/Users/junhuang/coolbuy/new-ai-proj/dotfiles/claude-skills"
|
||||||
|
TARGET_DIR="/Users/junhuang/coolbuy/claude-marketplace/plugins"
|
||||||
|
|
||||||
|
echo "Converting skills to plugins..."
|
||||||
|
|
||||||
|
# Loop through each skill directory
|
||||||
|
for skill_dir in "$SOURCE_DIR"/*; do
|
||||||
|
# Skip non-directories and special files
|
||||||
|
if [ ! -d "$skill_dir" ] || [ "$(basename "$skill_dir")" = ".git" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
skill_name=$(basename "$skill_dir")
|
||||||
|
plugin_name="${skill_name}-plugin"
|
||||||
|
plugin_dir="$TARGET_DIR/$plugin_name"
|
||||||
|
|
||||||
|
echo "Processing: $skill_name -> $plugin_name"
|
||||||
|
|
||||||
|
# Create plugin directory structure
|
||||||
|
mkdir -p "$plugin_dir/.claude-plugin"
|
||||||
|
mkdir -p "$plugin_dir/skills"
|
||||||
|
|
||||||
|
# Copy SKILL.md if exists
|
||||||
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
||||||
|
cp "$skill_dir/SKILL.md" "$plugin_dir/skills/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy skill.yaml if exists (for reference)
|
||||||
|
if [ -f "$skill_dir/skill.yaml" ]; then
|
||||||
|
cp "$skill_dir/skill.yaml" "$plugin_dir/.skill.yaml.original"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy any Python scripts or other files
|
||||||
|
find "$skill_dir" -type f \( -name "*.py" -o -name "*.sh" -o -name "*.js" -o -name "*.json" -o -name "*.md" \) -not -name "SKILL.md" -not -name "skill.yaml" -exec cp {} "$plugin_dir/" \;
|
||||||
|
|
||||||
|
# Copy subdirectories (like scripts/)
|
||||||
|
for subdir in "$skill_dir"/*; do
|
||||||
|
if [ -d "$subdir" ]; then
|
||||||
|
subdir_name=$(basename "$subdir")
|
||||||
|
cp -r "$subdir" "$plugin_dir/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Read version from skill.yaml if exists, otherwise use 1.0.0
|
||||||
|
version="1.0.0"
|
||||||
|
if [ -f "$skill_dir/skill.yaml" ]; then
|
||||||
|
yaml_version=$(grep "^version:" "$skill_dir/skill.yaml" | sed 's/version: *//' | tr -d '"' | tr -d "'")
|
||||||
|
if [ ! -z "$yaml_version" ]; then
|
||||||
|
version="$yaml_version"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read description from skill.yaml
|
||||||
|
description="Plugin for $skill_name"
|
||||||
|
if [ -f "$skill_dir/skill.yaml" ]; then
|
||||||
|
yaml_desc=$(grep "^description:" "$skill_dir/skill.yaml" | sed 's/description: *//' | tr -d '"')
|
||||||
|
if [ ! -z "$yaml_desc" ]; then
|
||||||
|
description="$yaml_desc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create plugin.json
|
||||||
|
cat > "$plugin_dir/.claude-plugin/plugin.json" << EOF
|
||||||
|
{
|
||||||
|
"name": "$plugin_name",
|
||||||
|
"description": "$description",
|
||||||
|
"version": "$version",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo " ✓ Created $plugin_name"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Conversion complete! Created $(ls -1 "$TARGET_DIR" | wc -l) plugins."
|
||||||
74
generate-marketplace.py
Normal file
74
generate-marketplace.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
script_dir = Path(__file__).parent.resolve()
|
||||||
|
plugins_dir = script_dir / "plugins"
|
||||||
|
marketplace_file = script_dir / ".claude-plugin" / "marketplace.json"
|
||||||
|
|
||||||
|
# Category mapping
|
||||||
|
def get_category_and_keywords(plugin_name):
|
||||||
|
if any(x in plugin_name for x in ['dev-', 'coding', 'frontend']):
|
||||||
|
return "development", ["development", "coding", "workflow"]
|
||||||
|
elif any(x in plugin_name for x in ['ops-', 'deploy', 'server']):
|
||||||
|
return "devops", ["devops", "deployment", "operations"]
|
||||||
|
elif any(x in plugin_name for x in ['ai-proj', 'req']):
|
||||||
|
return "productivity", ["project-management", "tasks", "requirements"]
|
||||||
|
elif any(x in plugin_name for x in ['feishu', 'wecom', 'siyuan']):
|
||||||
|
return "integration", ["integration", "automation", "productivity"]
|
||||||
|
elif 'biz-' in plugin_name:
|
||||||
|
return "business", ["business", "planning", "contracts"]
|
||||||
|
elif 'session' in plugin_name:
|
||||||
|
return "workflow", ["session", "workflow", "productivity"]
|
||||||
|
else:
|
||||||
|
return "utility", ["utility", "tools"]
|
||||||
|
|
||||||
|
# Collect plugins
|
||||||
|
plugins = []
|
||||||
|
for plugin_dir in sorted(plugins_dir.glob("*-plugin")):
|
||||||
|
if not plugin_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
manifest_path = plugin_dir / ".claude-plugin" / "plugin.json"
|
||||||
|
if not manifest_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(manifest_path) as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
|
||||||
|
plugin_name = plugin_dir.name
|
||||||
|
category, keywords = get_category_and_keywords(plugin_name)
|
||||||
|
|
||||||
|
plugins.append({
|
||||||
|
"name": plugin_name,
|
||||||
|
"source": f"./plugins/{plugin_name}",
|
||||||
|
"description": manifest.get("description", f"Plugin for {plugin_name}"),
|
||||||
|
"version": manifest.get("version", "1.0.0"),
|
||||||
|
"category": category,
|
||||||
|
"keywords": keywords,
|
||||||
|
"strict": False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create marketplace
|
||||||
|
marketplace = {
|
||||||
|
"name": "coolbuy-claude-plugins",
|
||||||
|
"owner": {
|
||||||
|
"name": "Donglin Lai (qiudl)",
|
||||||
|
"email": "qiudl@zhiyuncai.com"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"description": "Custom Claude Code plugins for development workflows, DevOps, and business operations",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"pluginRoot": "./plugins"
|
||||||
|
},
|
||||||
|
"plugins": plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write marketplace.json
|
||||||
|
with open(marketplace_file, 'w') as f:
|
||||||
|
json.dump(marketplace, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print(f"✓ Generated marketplace.json with {len(plugins)} plugins")
|
||||||
102
generate-marketplace.sh
Executable file
102
generate-marketplace.sh
Executable file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Generate marketplace.json from converted plugins
|
||||||
|
|
||||||
|
TARGET_DIR="/Users/junhuang/coolbuy/claude-marketplace/plugins"
|
||||||
|
MARKETPLACE_FILE="/Users/junhuang/coolbuy/claude-marketplace/.claude-plugin/marketplace.json"
|
||||||
|
|
||||||
|
echo "Generating marketplace.json..."
|
||||||
|
|
||||||
|
# Start JSON
|
||||||
|
cat > "$MARKETPLACE_FILE" << 'EOF'
|
||||||
|
{
|
||||||
|
"name": "coolbuy-claude-plugins",
|
||||||
|
"owner": {
|
||||||
|
"name": "Donglin Lai (qiudl)",
|
||||||
|
"email": "qiudl@zhiyuncai.com"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"description": "Custom Claude Code plugins for development workflows, DevOps, and business operations",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"pluginRoot": "./plugins"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Loop through each plugin and extract info
|
||||||
|
first=true
|
||||||
|
for plugin_dir in "$TARGET_DIR"/*-plugin; do
|
||||||
|
if [ ! -d "$plugin_dir" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
plugin_name=$(basename "$plugin_dir")
|
||||||
|
manifest="$plugin_dir/.claude-plugin/plugin.json"
|
||||||
|
|
||||||
|
if [ ! -f "$manifest" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract info from plugin.json using grep and sed
|
||||||
|
description=$(grep '"description"' "$manifest" | head -1 | sed 's/.*"description": *"\([^"]*\)".*/\1/')
|
||||||
|
version=$(grep '"version"' "$manifest" | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/')
|
||||||
|
|
||||||
|
# Determine category based on plugin name
|
||||||
|
category="utility"
|
||||||
|
keywords=""
|
||||||
|
|
||||||
|
case "$plugin_name" in
|
||||||
|
*dev-*|*coding*|*frontend*)
|
||||||
|
category="development"
|
||||||
|
keywords='["development", "coding", "workflow"]'
|
||||||
|
;;
|
||||||
|
*ops-*|*deploy*|*server*)
|
||||||
|
category="devops"
|
||||||
|
keywords='["devops", "deployment", "operations"]'
|
||||||
|
;;
|
||||||
|
*ai-proj*|*req*)
|
||||||
|
category="productivity"
|
||||||
|
keywords='["project-management", "tasks", "requirements"]'
|
||||||
|
;;
|
||||||
|
*feishu*|*wecom*|*siyuan*)
|
||||||
|
category="integration"
|
||||||
|
keywords='["integration", "automation", "productivity"]'
|
||||||
|
;;
|
||||||
|
*biz-*)
|
||||||
|
category="business"
|
||||||
|
keywords='["business", "planning", "contracts"]'
|
||||||
|
;;
|
||||||
|
*session*)
|
||||||
|
category="workflow"
|
||||||
|
keywords='["session", "workflow", "productivity"]'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Add comma if not first entry
|
||||||
|
if [ "$first" = true ]; then
|
||||||
|
first=false
|
||||||
|
else
|
||||||
|
echo "," >> "$MARKETPLACE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write plugin entry
|
||||||
|
cat >> "$MARKETPLACE_FILE" << ENTRY
|
||||||
|
{
|
||||||
|
"name": "$plugin_name",
|
||||||
|
"source": "$plugin_name",
|
||||||
|
"description": "$description",
|
||||||
|
"version": "$version",
|
||||||
|
"category": "$category",
|
||||||
|
"keywords": $keywords,
|
||||||
|
"strict": false
|
||||||
|
}ENTRY
|
||||||
|
done
|
||||||
|
|
||||||
|
# Close JSON
|
||||||
|
cat >> "$MARKETPLACE_FILE" << 'EOF'
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✓ Generated marketplace.json with $(grep -c '"name"' "$MARKETPLACE_FILE" | awk '{print $1-1}') plugins"
|
||||||
8
plugins/agent-swarm-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/agent-swarm-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "agent-swarm-plugin",
|
||||||
|
"description": "Multi-agent orchestration using OpenAI Swarm patterns. Coordinate specialized agents for complex development workflows with handoffs and context sharing.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
406
plugins/agent-swarm-plugin/skills/SKILL.md
Normal file
406
plugins/agent-swarm-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
---
|
||||||
|
name: agent-swarm
|
||||||
|
description: Multi-agent orchestration using OpenAI Swarm patterns. Coordinate specialized agents for complex development workflows with handoffs and context sharing.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Swarm - Multi-Agent Orchestration
|
||||||
|
|
||||||
|
基于 OpenAI Swarm 设计模式的多智能体协作系统,用于复杂开发任务的智能分解与协调。
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 1. Agent(智能体)
|
||||||
|
每个 Agent 是具有特定职责的专家:
|
||||||
|
- **Instructions**: Agent 的角色定义和行为准则
|
||||||
|
- **Functions**: Agent 可以调用的工具函数
|
||||||
|
- **Handoffs**: 何时移交给其他 Agent
|
||||||
|
|
||||||
|
### 2. Handoff(任务移交)
|
||||||
|
Agent 之间的控制权转移机制:
|
||||||
|
- 当前 Agent 完成自己的职责
|
||||||
|
- 识别需要其他专长
|
||||||
|
- 移交给最合适的 Agent
|
||||||
|
|
||||||
|
### 3. Context Variables(上下文变量)
|
||||||
|
跨 Agent 共享的状态:
|
||||||
|
- 项目目录
|
||||||
|
- 技术栈信息
|
||||||
|
- 当前进度
|
||||||
|
- 发现的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 预定义 Agent
|
||||||
|
|
||||||
|
### 1. Architect Agent(架构师)
|
||||||
|
**职责**: 理解需求、技术选型、设计系统架构
|
||||||
|
|
||||||
|
**何时使用**:
|
||||||
|
- 用户描述新功能或系统
|
||||||
|
- 需要技术方案设计
|
||||||
|
- 需要架构评审
|
||||||
|
|
||||||
|
**工具**:
|
||||||
|
- Read codebase
|
||||||
|
- Grep patterns
|
||||||
|
- 设计文档生成
|
||||||
|
|
||||||
|
**Handoff to**:
|
||||||
|
- Coder Agent(开始编码)
|
||||||
|
- Reviewer Agent(评审设计)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Coder Agent(编码者)
|
||||||
|
**职责**: 实现功能、编写代码、修复 bug
|
||||||
|
|
||||||
|
**何时使用**:
|
||||||
|
- 架构师完成设计
|
||||||
|
- 用户提出 bug 修复
|
||||||
|
- 需要代码重构
|
||||||
|
|
||||||
|
**工具**:
|
||||||
|
- Edit files
|
||||||
|
- Write files
|
||||||
|
- Git operations
|
||||||
|
|
||||||
|
**Handoff to**:
|
||||||
|
- Tester Agent(代码完成后)
|
||||||
|
- Architect Agent(遇到设计问题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Tester Agent(测试员)
|
||||||
|
**职责**: 编写测试、运行测试、验证功能
|
||||||
|
|
||||||
|
**何时使用**:
|
||||||
|
- 代码编写完成
|
||||||
|
- 需要测试覆盖
|
||||||
|
- 验证 bug 修复
|
||||||
|
|
||||||
|
**工具**:
|
||||||
|
- Run tests
|
||||||
|
- Write test cases
|
||||||
|
- Coverage reports
|
||||||
|
|
||||||
|
**Handoff to**:
|
||||||
|
- Deployer Agent(测试通过)
|
||||||
|
- Coder Agent(发现问题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Deployer Agent(部署员)
|
||||||
|
**职责**: 构建镜像、部署服务、监控上线
|
||||||
|
|
||||||
|
**何时使用**:
|
||||||
|
- 测试全部通过
|
||||||
|
- 需要发布到环境
|
||||||
|
- 需要回滚版本
|
||||||
|
|
||||||
|
**工具**:
|
||||||
|
- Docker build
|
||||||
|
- SSH deployment
|
||||||
|
- Health checks
|
||||||
|
|
||||||
|
**Handoff to**:
|
||||||
|
- Monitor Agent(部署完成)
|
||||||
|
- Coder Agent(部署失败)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Reviewer Agent(评审员)
|
||||||
|
**职责**: 代码审查、文档审查、安全检查
|
||||||
|
|
||||||
|
**何时使用**:
|
||||||
|
- PR 创建后
|
||||||
|
- 重要功能完成
|
||||||
|
- 需要质量把关
|
||||||
|
|
||||||
|
**工具**:
|
||||||
|
- Diff analysis
|
||||||
|
- Security scan
|
||||||
|
- Best practices check
|
||||||
|
|
||||||
|
**Handoff to**:
|
||||||
|
- Coder Agent(需要修改)
|
||||||
|
- Deployer Agent(审查通过)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 基本调用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/swarm start "在 new-ai-proj 中实现任务批量删除功能"
|
||||||
|
```
|
||||||
|
|
||||||
|
**执行流程**:
|
||||||
|
1. **Architect** 分析需求 → 设计 API 和前端交互
|
||||||
|
2. **Coder** 实现后端 API → 实现前端 UI
|
||||||
|
3. **Tester** 编写单元测试 → 运行测试
|
||||||
|
4. **Reviewer** 代码审查 → 安全检查
|
||||||
|
5. **Deployer** 部署到 staging → 验证功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 指定起始 Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/swarm coder "修复 backend/handlers/task_handler.go 的空指针 bug"
|
||||||
|
```
|
||||||
|
|
||||||
|
直接从 Coder Agent 开始,跳过架构设计阶段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 传递上下文
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/swarm start "优化数据库查询性能" \
|
||||||
|
--context project=/Users/coolbuy-dev/coding/new-ai-proj \
|
||||||
|
--context stack=Go,PostgreSQL,Redis \
|
||||||
|
--context module=backend/services
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 查看执行轨迹
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/swarm trace
|
||||||
|
```
|
||||||
|
|
||||||
|
显示 Agent 调用链:
|
||||||
|
```
|
||||||
|
Architect → analyzed requirements (3 min)
|
||||||
|
↓ handoff: "Design complete, ready for implementation"
|
||||||
|
Coder → implemented 5 files (12 min)
|
||||||
|
↓ handoff: "Code complete, needs testing"
|
||||||
|
Tester → wrote 8 test cases, all passed (5 min)
|
||||||
|
↓ handoff: "Tests passed, ready for review"
|
||||||
|
Reviewer → approved with 2 suggestions (2 min)
|
||||||
|
↓ handoff: "Approved, ready for deployment"
|
||||||
|
Deployer → deployed to staging, health check OK (3 min)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置文件
|
||||||
|
|
||||||
|
### swarm.yaml
|
||||||
|
|
||||||
|
在项目根目录创建 `swarm.yaml` 自定义 Agent 行为:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agents:
|
||||||
|
architect:
|
||||||
|
instructions: |
|
||||||
|
你是系统架构师,专注于 Go + Vue.js 技术栈。
|
||||||
|
遵循 RESTful API 设计原则。
|
||||||
|
考虑性能、安全性、可维护性。
|
||||||
|
max_turns: 5
|
||||||
|
|
||||||
|
coder:
|
||||||
|
instructions: |
|
||||||
|
你是 Go 后端工程师和 Vue.js 前端工程师。
|
||||||
|
编写清晰、简洁、高性能的代码。
|
||||||
|
遵循项目现有代码风格。
|
||||||
|
tools:
|
||||||
|
- Edit
|
||||||
|
- Write
|
||||||
|
- Bash
|
||||||
|
max_turns: 10
|
||||||
|
|
||||||
|
tester:
|
||||||
|
instructions: |
|
||||||
|
你是测试工程师,编写全面的测试用例。
|
||||||
|
确保边界条件、错误处理、并发安全。
|
||||||
|
tools:
|
||||||
|
- Bash
|
||||||
|
- Write
|
||||||
|
test_command: "go test ./... -v"
|
||||||
|
max_turns: 5
|
||||||
|
|
||||||
|
context_variables:
|
||||||
|
project_root: /Users/coolbuy-dev/coding/new-ai-proj
|
||||||
|
backend_lang: Go 1.21
|
||||||
|
frontend_framework: Vue 3
|
||||||
|
database: PostgreSQL 15
|
||||||
|
deployment_target: staging.ai.pipexerp.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 高级功能
|
||||||
|
|
||||||
|
### 1. 自定义 Agent
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agents:
|
||||||
|
database-optimizer:
|
||||||
|
instructions: |
|
||||||
|
你是数据库性能优化专家。
|
||||||
|
分析慢查询、优化索引、设计缓存策略。
|
||||||
|
functions:
|
||||||
|
- explain_analyze
|
||||||
|
- create_index
|
||||||
|
- cache_design
|
||||||
|
handoff_to:
|
||||||
|
- coder # 实现优化方案
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 条件 Handoff
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
handoff_rules:
|
||||||
|
- from: tester
|
||||||
|
to: coder
|
||||||
|
condition: "test_pass_rate < 90%"
|
||||||
|
message: "测试失败率超过 10%,需要修复"
|
||||||
|
|
||||||
|
- from: tester
|
||||||
|
to: deployer
|
||||||
|
condition: "test_pass_rate == 100%"
|
||||||
|
message: "所有测试通过,可以部署"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 并行 Agent
|
||||||
|
|
||||||
|
对于独立任务,多个 Agent 可以并行工作:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/swarm parallel \
|
||||||
|
"coder: 实现后端 API" \
|
||||||
|
"coder: 实现前端 UI" \
|
||||||
|
"tester: 编写 API 测试"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 Remote Coding 集成
|
||||||
|
|
||||||
|
在 OpenClaw 中调用本地 Claude Code 执行 Swarm 工作流:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OpenClaw 调用 Melbourne Claude Code
|
||||||
|
ssh melbourne "cd /Users/coolbuy-dev/coding/new-ai-proj && \
|
||||||
|
/opt/homebrew/bin/claude --dangerously-skip-permissions \
|
||||||
|
-p '/swarm start 实现任务批量删除功能'"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实际案例
|
||||||
|
|
||||||
|
### 案例 1: 新功能开发
|
||||||
|
|
||||||
|
**任务**: "为 AI-Proj 实现需求批量导出功能"
|
||||||
|
|
||||||
|
**执行过程**:
|
||||||
|
1. **Architect**:
|
||||||
|
- 分析需求:导出格式(Excel/PDF)、筛选条件、数据脱敏
|
||||||
|
- 设计 API: `POST /api/v1/requirements/export`
|
||||||
|
- 设计前端:导出按钮、进度条、下载链接
|
||||||
|
|
||||||
|
2. **Coder**:
|
||||||
|
- 后端实现 export service
|
||||||
|
- 前端实现导出 UI 组件
|
||||||
|
- 集成 file download 功能
|
||||||
|
|
||||||
|
3. **Tester**:
|
||||||
|
- 测试大量数据导出(1000+ 需求)
|
||||||
|
- 测试并发导出
|
||||||
|
- 测试下载失败重试
|
||||||
|
|
||||||
|
4. **Reviewer**:
|
||||||
|
- 检查文件大小限制
|
||||||
|
- 检查内存泄漏风险
|
||||||
|
- 检查数据权限控制
|
||||||
|
|
||||||
|
5. **Deployer**:
|
||||||
|
- 部署到 staging
|
||||||
|
- 验证导出功能
|
||||||
|
- 监控资源使用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 案例 2: Bug 修复
|
||||||
|
|
||||||
|
**任务**: "修复任务详情页加载缓慢问题"
|
||||||
|
|
||||||
|
**执行过程**:
|
||||||
|
1. **Architect**:
|
||||||
|
- 分析性能瓶颈:N+1 查询问题
|
||||||
|
- 设计优化方案:使用 JOIN 和预加载
|
||||||
|
|
||||||
|
2. **Coder**:
|
||||||
|
- 优化数据库查询
|
||||||
|
- 添加 Redis 缓存
|
||||||
|
- 更新前端数据获取逻辑
|
||||||
|
|
||||||
|
3. **Tester**:
|
||||||
|
- 性能测试:加载时间从 3s → 300ms
|
||||||
|
- 并发测试:100 用户同时访问
|
||||||
|
- 缓存一致性测试
|
||||||
|
|
||||||
|
4. **Deployer**:
|
||||||
|
- 灰度发布到 10% 用户
|
||||||
|
- 监控性能指标
|
||||||
|
- 全量发布
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **明确任务范围**: 复杂任务交给 Swarm,简单任务直接执行
|
||||||
|
2. **合理设置 max_turns**: 避免 Agent 陷入死循环
|
||||||
|
3. **记录 Handoff 原因**: 便于追溯和调试
|
||||||
|
4. **定期审查轨迹**: 优化 Agent 协作流程
|
||||||
|
5. **利用 Context Variables**: 避免重复传递信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
| 问题 | 原因 | 解决方案 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Agent 一直循环 | max_turns 设置过大 | 降低 max_turns,添加明确的 handoff 条件 |
|
||||||
|
| Handoff 失败 | 目标 Agent 未定义 | 检查 swarm.yaml 配置 |
|
||||||
|
| 上下文丢失 | Context Variables 未传递 | 在 handoff 时显式传递 context |
|
||||||
|
| 执行太慢 | 串行执行可并行任务 | 使用 `/swarm parallel` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与其他 Skills 集成
|
||||||
|
|
||||||
|
- **dev-coding**: Coder Agent 使用 dev-coding 的编码规范
|
||||||
|
- **dev-test**: Tester Agent 使用 dev-test 的测试策略
|
||||||
|
- **ops-tools**: Deployer Agent 使用 ops-tools 进行部署
|
||||||
|
- **ai-proj**: 所有 Agent 使用 ai-proj MCP 进行任务同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 命令速查
|
||||||
|
|
||||||
|
| 命令 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `/swarm start <task>` | 启动 Swarm 工作流(从 Architect 开始) |
|
||||||
|
| `/swarm <agent> <task>` | 从指定 Agent 开始 |
|
||||||
|
| `/swarm parallel <tasks>` | 并行执行多个任务 |
|
||||||
|
| `/swarm trace` | 查看执行轨迹 |
|
||||||
|
| `/swarm config` | 显示当前配置 |
|
||||||
|
| `/swarm agents` | 列出所有可用 Agent |
|
||||||
|
| `/swarm stop` | 终止当前 Swarm 执行 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考资料
|
||||||
|
|
||||||
|
- [OpenAI Swarm 文档](https://github.com/openai/swarm)
|
||||||
|
- [Multi-Agent Systems 设计模式](https://arxiv.org/abs/2308.00352)
|
||||||
|
- [Claude Code Skills 文档](https://docs.anthropic.com/claude-code/skills)
|
||||||
8
plugins/ai-proj-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/ai-proj-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-proj-plugin",
|
||||||
|
"description": "AI project management via REST API. Works out of the box!",
|
||||||
|
"version": "2.0.1",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
plugins/ai-proj-plugin/README.md
Normal file
136
plugins/ai-proj-plugin/README.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# ai-proj Plugin
|
||||||
|
|
||||||
|
AI project task and requirement management through direct REST API calls.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Task Management**: Create, list, update, and complete tasks
|
||||||
|
- **Requirement Management**: Full requirement lifecycle (draft → review → development → complete)
|
||||||
|
- **Documentation**: Create and manage task documents
|
||||||
|
- **Daily Focus**: Plan and view today's tasks
|
||||||
|
- **Direct API Integration**: Uses REST API directly
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Just install the plugin and start using it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install ai-proj-plugin@coolbuy-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it! No configuration needed.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
This plugin provides **Agent Skills** that I (Claude) automatically use when you mention relevant topics.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Task Management:**
|
||||||
|
- "Create a task: implement user authentication"
|
||||||
|
- "List all in-progress tasks"
|
||||||
|
- "Complete task 123"
|
||||||
|
- "Show me task 456"
|
||||||
|
|
||||||
|
**Requirement Management:**
|
||||||
|
- "Create requirement: add dark mode support"
|
||||||
|
- "List requirements in draft status"
|
||||||
|
- "Submit requirement 789 for review"
|
||||||
|
- "Approve requirement 789"
|
||||||
|
|
||||||
|
**Daily Focus:**
|
||||||
|
- "Show today's tasks"
|
||||||
|
- "Add task 123 to today's focus"
|
||||||
|
|
||||||
|
**Documents:**
|
||||||
|
- "Show the document for task 123"
|
||||||
|
- "Update task 456's document with: [content]"
|
||||||
|
|
||||||
|
## Configuration (Optional)
|
||||||
|
|
||||||
|
The plugin uses a default API token that works for most users. If you need to use a custom token:
|
||||||
|
|
||||||
|
### Environment Variable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AIPROJ_TOKEN="your_custom_token_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this to your `~/.zshrc` or `~/.bashrc` to make it persistent.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
This plugin uses a simple, straightforward approach:
|
||||||
|
|
||||||
|
1. Makes direct REST API calls to `https://ai.pipexerp.com/api/v1`
|
||||||
|
2. Uses `curl` for HTTP requests
|
||||||
|
3. Automatically finds the API token from:
|
||||||
|
- `$AIPROJ_TOKEN` environment variable
|
||||||
|
- Built-in default token
|
||||||
|
|
||||||
|
## Advantages
|
||||||
|
|
||||||
|
- ✅ **No setup required** - works immediately after installation
|
||||||
|
- ✅ **No server process** - simple and lightweight
|
||||||
|
- ✅ **No dependencies** - just needs `curl` (built into all systems)
|
||||||
|
- ✅ **Simple architecture** - direct API calls
|
||||||
|
- ✅ **Easy debugging** - can see exact API calls
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The plugin uses these endpoints:
|
||||||
|
|
||||||
|
- `GET /tasks` - List tasks
|
||||||
|
- `POST /tasks` - Create task
|
||||||
|
- `GET /tasks/{id}` - Get task details
|
||||||
|
- `PATCH /tasks/{id}` - Update task
|
||||||
|
- `DELETE /tasks/{id}` - Delete task
|
||||||
|
- `GET /requirements` - List requirements
|
||||||
|
- `POST /requirements` - Create requirement
|
||||||
|
- `POST /requirements/{id}/actions` - Update requirement status
|
||||||
|
- `GET /tasks/{id}/document` - Get task document
|
||||||
|
- `PUT /tasks/{id}/document` - Update task document
|
||||||
|
- `GET /daily-focus/tasks` - Get today's tasks
|
||||||
|
- `POST /daily-focus/tasks` - Add task to today
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Unauthorized" or API errors
|
||||||
|
|
||||||
|
Check your token:
|
||||||
|
```bash
|
||||||
|
echo $AIPROJ_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
If empty, either set the environment variable or the plugin will use the default token.
|
||||||
|
|
||||||
|
### API not responding
|
||||||
|
|
||||||
|
Verify the API is accessible:
|
||||||
|
```bash
|
||||||
|
curl -I https://ai.pipexerp.com/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skills not working
|
||||||
|
|
||||||
|
- Agent Skills don't create slash commands
|
||||||
|
- Just ask naturally: "create a task", "list tasks", etc.
|
||||||
|
- The plugin is automatically active when installed
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
**API Base**: `https://ai.pipexerp.com/api/v1`
|
||||||
|
**Authentication**: Bearer token in `Authorization` header
|
||||||
|
**Data Format**: JSON
|
||||||
|
**Date Format**: ISO 8601 (YYYY-MM-DD)
|
||||||
|
**Default Project ID**: 1
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
For detailed API documentation and examples, see `skills/SKILL.md`.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- Repository: git@gitea.pipexerp.com:huangjun/claude-marketplace.git
|
||||||
|
- Author: qiudl@zhiyuncai.com
|
||||||
|
|
||||||
1384
plugins/ai-proj-plugin/skills/SKILL.md
Normal file
1384
plugins/ai-proj-plugin/skills/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
8
plugins/biz-contract-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/biz-contract-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "biz-contract-plugin",
|
||||||
|
"description": "商务合同撰写。支持多种合同类型:软件订阅合同、软件定制开发合同、物流合同、销售服务合同、贸易合同等。当用户提到合同、协议、签约、合作协议相关任务时自动激活。",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
742
plugins/biz-contract-plugin/skills/SKILL.md
Normal file
742
plugins/biz-contract-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
---
|
||||||
|
name: biz-contract
|
||||||
|
description: 商务合同撰写。支持多种合同类型:软件订阅合同、软件定制开发合同、物流合同、销售服务合同、贸易合同等。当用户提到合同、协议、签约、合作协议相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 商务合同撰写技能 (biz-contract)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本技能用于辅助商务合同的撰写与管理,支持多家公司主体、多种合同类型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用户公司信息
|
||||||
|
|
||||||
|
### 公司主体列表
|
||||||
|
|
||||||
|
| 公司简称 | 公司全称 | 纳税人识别号 | 地址 | 联系电话 | 法定代表人 |
|
||||||
|
|----------|----------|--------------|------|----------|------------|
|
||||||
|
| 智慧云彩 | 北京智慧云彩电子商务科技有限公司 | 91110114MA004M80XX | 北京市海淀区丰智东路13号院1号楼1层101 | - | - |
|
||||||
|
| 欢乐宿 | 北京欢乐宿供应链科技有限公司 | 91110113MACWJKYU8P | 北京市顺义区军营南街10号院3幢3层327室 | - | - |
|
||||||
|
| 对丝 | 北京对丝信息技术有限公司 | 91110113MAE4XXHR5E | 北京市顺义区军营南街10号院3幢3层3216室 | - | - |
|
||||||
|
| 妗晨 | 重庆妗晨工贸有限公司 | 91500104MA7EJTPA6D | 重庆市大渡口区跳磴镇海康路106号1-1 | 15213397998 | - |
|
||||||
|
| 名风 | 北京名风新能源科技有限公司 | 91110106092440790K | 北京市密云区西田各庄镇雁密路99号601室-3509(集群注册) | - | 魏小健 |
|
||||||
|
| 鸿侨 | 沈阳鸿侨物流有限公司 | 91210113MADBXPU93P | 辽宁省沈阳市沈北新区蒲河路49-2号(1-29-2) | 15567339320 | 禚子乔 |
|
||||||
|
|
||||||
|
### 开户信息
|
||||||
|
|
||||||
|
| 公司简称 | 开户银行 | 银行账号 |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| 智慧云彩 | 招商银行股份有限公司北京立水桥支行 | 110922001210512 |
|
||||||
|
| 欢乐宿 | 中国工商银行北京南十里支行 | 0200206709200036024 |
|
||||||
|
| 对丝 | 招商银行股份有限公司北京顺义支行 | 110961225610001 |
|
||||||
|
| 妗晨 | 中国农业银行股份有限公司重庆大渡口天安支行 | 31230401040004386 |
|
||||||
|
| 名风 | 中国建设银行北京黄亦路支行 | 11001181700052501668 |
|
||||||
|
| 鸿侨 | 中国建设银行股份有限公司沈阳辉山支行 | 21050149004200001104 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 支持的合同类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 典型场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 软件订阅合同 | SaaS 软件服务订阅 | 云服务、在线系统 |
|
||||||
|
| 软件定制开发合同 | 软件外包开发 | 项目制开发 |
|
||||||
|
| 物流合同 | 物流运输服务 | 货运、仓储 |
|
||||||
|
| 销售服务合同 | 产品销售与服务 | 商品买卖 |
|
||||||
|
| 贸易合同 | 商品贸易 | 进出口、批发 |
|
||||||
|
| 技术服务合同 | 技术咨询、支持 | IT 服务 |
|
||||||
|
| 保密协议 (NDA) | 信息保密 | 合作前签署 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 合同撰写流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 确定合同类型
|
||||||
|
├── 识别业务场景
|
||||||
|
├── 选择合同模板
|
||||||
|
└── 确定双方主体
|
||||||
|
|
||||||
|
2. 收集合同要素
|
||||||
|
├── 甲方/乙方信息
|
||||||
|
├── 标的物/服务内容
|
||||||
|
├── 价款与支付方式
|
||||||
|
├── 履行期限
|
||||||
|
└── 特殊条款需求
|
||||||
|
|
||||||
|
3. 生成合同草稿
|
||||||
|
├── 套用模板
|
||||||
|
├── 填充具体内容
|
||||||
|
└── 调整条款
|
||||||
|
|
||||||
|
4. 审核与完善
|
||||||
|
├── 法律合规检查
|
||||||
|
├── 风险条款审查
|
||||||
|
└── 最终定稿
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 合同模板
|
||||||
|
|
||||||
|
### 1. 软件订阅合同 (SaaS)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 软件订阅服务合同
|
||||||
|
|
||||||
|
**合同编号**: [自动生成或手填]
|
||||||
|
|
||||||
|
**甲方(服务提供方)**: [公司全称]
|
||||||
|
统一社会信用代码: [代码]
|
||||||
|
地址: [地址]
|
||||||
|
法定代表人: [姓名]
|
||||||
|
|
||||||
|
**乙方(订阅方)**: [公司全称]
|
||||||
|
统一社会信用代码: [代码]
|
||||||
|
地址: [地址]
|
||||||
|
法定代表人: [姓名]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
鉴于甲方拥有 [软件名称] 的合法运营权,乙方有意订阅使用该软件服务,双方本着平等互利的原则,经友好协商,达成如下协议:
|
||||||
|
|
||||||
|
## 第一条 软件服务内容
|
||||||
|
|
||||||
|
1.1 软件名称:[软件名称]
|
||||||
|
1.2 服务内容:[功能模块描述,或"详见系统操作手册"]
|
||||||
|
1.3 服务方式:SaaS 云服务模式,乙方通过互联网访问使用
|
||||||
|
1.4 账号数量:[数量] 个用户账号
|
||||||
|
|
||||||
|
## 第二条 服务期限
|
||||||
|
|
||||||
|
2.1 服务期限:自 [起始日期] 至 [结束日期]
|
||||||
|
2.2 续约条款:合同期满前 [30] 日,双方均未提出书面异议的,本合同自动续期 [1] 年,届时服务费按届时标准执行
|
||||||
|
|
||||||
|
## 第三条 服务费用与支付
|
||||||
|
|
||||||
|
3.1 服务费用:
|
||||||
|
- 月付标准:人民币 [金额] 元/月
|
||||||
|
- 年付标准:人民币 [金额] 元/年
|
||||||
|
|
||||||
|
3.2 支付方式:[年付/月付/季付]
|
||||||
|
3.3 付款时间:乙方应于本合同签订后 [N] 个工作日内,在收到甲方开具的发票后,一次性支付服务费用
|
||||||
|
3.4 付款账户:
|
||||||
|
开户名称:[甲方公司全称]
|
||||||
|
开户银行:[银行名称]
|
||||||
|
银行账号:[账号]
|
||||||
|
|
||||||
|
## 第四条 甲方权利与义务
|
||||||
|
|
||||||
|
4.1 甲方应保证软件服务的稳定运行,年可用性不低于 99.5%
|
||||||
|
4.2 甲方应提供必要的技术支持和培训服务
|
||||||
|
4.3 甲方应对乙方的业务数据严格保密
|
||||||
|
4.4 甲方有权在乙方未按时付款时暂停服务
|
||||||
|
|
||||||
|
## 第五条 乙方权利与义务
|
||||||
|
|
||||||
|
5.1 乙方有权在服务期内正常使用软件服务
|
||||||
|
5.2 乙方应按约定及时支付服务费用
|
||||||
|
5.3 乙方不得将账号转让、出租给第三方
|
||||||
|
5.4 乙方应妥善保管账号密码,因保管不善造成的损失由乙方承担
|
||||||
|
|
||||||
|
## 第六条 数据安全与保密
|
||||||
|
|
||||||
|
6.1 甲方应采取合理的技术措施保护乙方数据安全
|
||||||
|
6.2 未经对方书面同意,任何一方不得向第三方披露本合同内容及对方商业秘密
|
||||||
|
6.3 保密义务在合同终止后 [2] 年内继续有效
|
||||||
|
|
||||||
|
## 第七条 违约责任
|
||||||
|
|
||||||
|
7.1 任何一方违反本合同约定,应向守约方支付合同总金额 [10]% 的违约金
|
||||||
|
7.2 因甲方原因导致服务中断超过 [24] 小时的,甲方应按日退还相应服务费
|
||||||
|
|
||||||
|
## 第八条 合同变更与解除
|
||||||
|
|
||||||
|
8.1 经双方协商一致,可以变更或解除本合同
|
||||||
|
8.2 乙方提前解约的,已付费用不予退还
|
||||||
|
8.3 甲方提前解约的,应退还乙方剩余期限的服务费用
|
||||||
|
|
||||||
|
## 第九条 争议解决
|
||||||
|
|
||||||
|
9.1 本合同的签订、履行、解释均适用中华人民共和国法律
|
||||||
|
9.2 双方因履行本合同发生争议,应友好协商解决;协商不成的,任何一方均可向甲方所在地人民法院提起诉讼
|
||||||
|
|
||||||
|
## 第十条 其他条款
|
||||||
|
|
||||||
|
10.1 本合同一式两份,双方各执一份,具有同等法律效力
|
||||||
|
10.2 本合同自双方签字盖章之日起生效
|
||||||
|
10.3 本合同未尽事宜,由双方另行协商签订补充协议
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**甲方(盖章)**: **乙方(盖章)**:
|
||||||
|
|
||||||
|
法定代表人/授权代表: 法定代表人/授权代表:
|
||||||
|
|
||||||
|
日期: 日期:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 软件定制开发合同
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 软件定制开发合同
|
||||||
|
|
||||||
|
**合同编号**: [编号]
|
||||||
|
|
||||||
|
**甲方(委托方)**: [公司全称]
|
||||||
|
**乙方(开发方)**: [公司全称]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一条 项目内容
|
||||||
|
|
||||||
|
1.1 项目名称:[项目名称]
|
||||||
|
1.2 开发内容:[详细描述或附件《需求规格说明书》]
|
||||||
|
1.3 技术要求:[技术栈、性能指标等]
|
||||||
|
1.4 交付物:
|
||||||
|
- 软件源代码
|
||||||
|
- 部署文档
|
||||||
|
- 操作手册
|
||||||
|
- [其他]
|
||||||
|
|
||||||
|
## 第二条 开发周期
|
||||||
|
|
||||||
|
2.1 总工期:[N] 个工作日
|
||||||
|
2.2 里程碑:
|
||||||
|
| 阶段 | 内容 | 交付时间 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 需求确认 | 需求规格说明书 | [日期] |
|
||||||
|
| 设计阶段 | 设计文档 | [日期] |
|
||||||
|
| 开发阶段 | 功能开发完成 | [日期] |
|
||||||
|
| 测试阶段 | 测试报告 | [日期] |
|
||||||
|
| 验收交付 | 全部交付物 | [日期] |
|
||||||
|
|
||||||
|
## 第三条 合同金额与支付
|
||||||
|
|
||||||
|
3.1 合同总金额:人民币 [金额] 元(大写:[大写金额])
|
||||||
|
3.2 支付方式:
|
||||||
|
- 合同签订后 [N] 日内,支付 [30]%,即 [金额] 元
|
||||||
|
- 需求确认后 [N] 日内,支付 [30]%,即 [金额] 元
|
||||||
|
- 验收通过后 [N] 日内,支付 [40]%,即 [金额] 元
|
||||||
|
|
||||||
|
## 第四条 验收标准
|
||||||
|
|
||||||
|
4.1 乙方完成开发后,应书面通知甲方验收
|
||||||
|
4.2 甲方应在收到通知后 [10] 个工作日内完成验收
|
||||||
|
4.3 验收标准以《需求规格说明书》为准
|
||||||
|
4.4 甲方逾期未验收且未提出书面异议的,视为验收通过
|
||||||
|
|
||||||
|
## 第五条 知识产权
|
||||||
|
|
||||||
|
5.1 定制开发的软件著作权归 [甲方/乙方/共有]
|
||||||
|
5.2 乙方保证交付的软件不侵犯任何第三方知识产权
|
||||||
|
|
||||||
|
## 第六条 质保与维护
|
||||||
|
|
||||||
|
6.1 免费质保期:验收通过后 [12] 个月
|
||||||
|
6.2 质保期内,乙方应免费修复软件缺陷
|
||||||
|
6.3 质保期后,双方可另行签订维护协议
|
||||||
|
|
||||||
|
## 第七条 违约责任
|
||||||
|
|
||||||
|
7.1 乙方逾期交付,每逾期一日,应向甲方支付合同总金额 [0.5]‰ 的违约金
|
||||||
|
7.2 甲方逾期付款,每逾期一日,应向乙方支付应付金额 [0.5]‰ 的违约金
|
||||||
|
|
||||||
|
## 第八条 其他条款
|
||||||
|
|
||||||
|
[参照标准条款]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**甲方(盖章)**: **乙方(盖章)**:
|
||||||
|
|
||||||
|
日期: 日期:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 物流服务合同
|
||||||
|
|
||||||
|
**物流服务增值税税率:9%**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 物流服务合同
|
||||||
|
|
||||||
|
**合同编号**: [编号]
|
||||||
|
|
||||||
|
**甲方(托运方)**: [公司全称]
|
||||||
|
**统一社会信用代码**: [代码]
|
||||||
|
**地址**: [地址]
|
||||||
|
**联系电话**: [电话]
|
||||||
|
|
||||||
|
**乙方(承运方)**: [公司全称]
|
||||||
|
**统一社会信用代码**: [代码]
|
||||||
|
**地址**: [地址]
|
||||||
|
**法定代表人**: [姓名]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一条 运输服务内容
|
||||||
|
|
||||||
|
1.1 服务类型:[整车运输/零担运输/仓储配送]
|
||||||
|
1.2 运输路线:[起点] 至 [终点]
|
||||||
|
1.3 货物类型:[货物描述]
|
||||||
|
|
||||||
|
## 第二条 服务期限
|
||||||
|
|
||||||
|
2.1 合同有效期:自 [起始日期] 至 [结束日期]
|
||||||
|
|
||||||
|
## 第三条 运费与结算
|
||||||
|
|
||||||
|
3.1 计费方式:[按重量/体积/件数/趟次]
|
||||||
|
3.2 运费标准:[具体标准]
|
||||||
|
3.3 结算周期:[月结/单结]
|
||||||
|
3.4 付款期限:甲方在收到乙方对账单并确认无误后 [N] 个工作日内支付运费
|
||||||
|
|
||||||
|
## 第四条 发票与税务
|
||||||
|
|
||||||
|
4.1 发票类型:增值税专用发票
|
||||||
|
4.2 税率:9%(交通运输服务)
|
||||||
|
4.3 开票时间:乙方应在甲方付款前开具发票
|
||||||
|
4.4 发票内容:运输服务费
|
||||||
|
|
||||||
|
### 甲方开票信息(付款方)
|
||||||
|
- 名称:[甲方公司全称]
|
||||||
|
- 纳税人识别号:[税号]
|
||||||
|
- 地址、电话:[地址] [电话]
|
||||||
|
- 开户行及账号:[开户银行] [账号]
|
||||||
|
|
||||||
|
### 乙方开票信息(收款方)
|
||||||
|
- 名称:[乙方公司全称]
|
||||||
|
- 纳税人识别号:[税号]
|
||||||
|
- 地址、电话:[地址] [电话]
|
||||||
|
- 开户行及账号:[开户银行] [账号]
|
||||||
|
|
||||||
|
## 第五条 甲方权利与义务
|
||||||
|
|
||||||
|
5.1 如实申报货物名称、数量、重量
|
||||||
|
5.2 按约定及时支付运费
|
||||||
|
5.3 提供必要的装卸条件
|
||||||
|
|
||||||
|
## 第六条 乙方权利与义务
|
||||||
|
|
||||||
|
6.1 按时提货、送货
|
||||||
|
6.2 保证货物运输安全
|
||||||
|
6.3 提供运输单据和签收证明
|
||||||
|
6.4 按约定开具增值税专用发票
|
||||||
|
|
||||||
|
## 第七条 货损赔偿
|
||||||
|
|
||||||
|
7.1 因乙方原因造成货物损失,按货物实际价值赔偿,最高不超过运费的 [N] 倍
|
||||||
|
7.2 甲方应在签收时验货,当场提出异议
|
||||||
|
|
||||||
|
## 第八条 其他条款
|
||||||
|
|
||||||
|
[参照标准条款]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 签章
|
||||||
|
|
||||||
|
| | 甲方 | 乙方 |
|
||||||
|
|---|---|---|
|
||||||
|
| 单位名称 | | |
|
||||||
|
| 法定代表人/授权代表 |(签字)|(签字)|
|
||||||
|
| 单位盖章 |(盖章处)|(盖章处)|
|
||||||
|
| 签订日期 | 年 月 日 | 年 月 日 |
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 物流合同开票信息速查
|
||||||
|
|
||||||
|
| 公司简称 | 公司全称 | 纳税人识别号 | 开票地址电话 | 开户行及账号 |
|
||||||
|
|----------|----------|--------------|--------------|--------------|
|
||||||
|
| 妗晨 | 重庆妗晨工贸有限公司 | 91500104MA7EJTPA6D | 重庆市大渡口区跳磴镇海康路106号1-1 / 15213397998 | 中国农业银行重庆大渡口天安支行 31230401040004386 |
|
||||||
|
| 名风 | 北京名风新能源科技有限公司 | 91110106092440790K | 北京市密云区西田各庄镇雁密路99号601室-3509 / - | 中国建设银行北京黄亦路支行 11001181700052501668 |
|
||||||
|
| 鸿侨 | 沈阳鸿侨物流有限公司 | 91210113MADBXPU93P | 辽宁省沈阳市沈北新区蒲河路49-2号(1-29-2) / 15567339320 | 中国建设银行沈阳辉山支行 21050149004200001104 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 销售服务合同
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 销售服务合同
|
||||||
|
|
||||||
|
**合同编号**: [编号]
|
||||||
|
|
||||||
|
**甲方(销售方)**: [公司全称]
|
||||||
|
**乙方(购买方)**: [公司全称]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一条 产品/服务内容
|
||||||
|
|
||||||
|
| 序号 | 名称 | 规格型号 | 单位 | 数量 | 单价(元) | 金额(元) |
|
||||||
|
|------|------|----------|------|------|------------|------------|
|
||||||
|
| 1 | | | | | | |
|
||||||
|
| 2 | | | | | | |
|
||||||
|
| **合计** | | | | | | **[总金额]** |
|
||||||
|
|
||||||
|
## 第二条 合同金额
|
||||||
|
|
||||||
|
2.1 合同总金额:人民币 [金额] 元(大写:[大写金额])
|
||||||
|
2.2 以上价格 [含/不含] 增值税
|
||||||
|
|
||||||
|
## 第三条 交付方式
|
||||||
|
|
||||||
|
3.1 交付时间:[日期]
|
||||||
|
3.2 交付地点:[地址]
|
||||||
|
3.3 交付方式:[送货上门/自提/物流]
|
||||||
|
|
||||||
|
## 第四条 付款方式
|
||||||
|
|
||||||
|
4.1 [全款预付/货到付款/分期付款]
|
||||||
|
4.2 付款账户:[账户信息]
|
||||||
|
|
||||||
|
## 第五条 验收标准
|
||||||
|
|
||||||
|
5.1 乙方应在收货后 [N] 日内完成验收
|
||||||
|
5.2 验收标准:[标准描述]
|
||||||
|
|
||||||
|
## 第六条 售后服务
|
||||||
|
|
||||||
|
6.1 质保期:[N] 个月
|
||||||
|
6.2 质保内容:[描述]
|
||||||
|
|
||||||
|
## 第七条 违约责任
|
||||||
|
|
||||||
|
[标准条款]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**甲方(盖章)**: **乙方(盖章)**:
|
||||||
|
|
||||||
|
日期: 日期:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 贸易合同
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 贸易合同
|
||||||
|
|
||||||
|
**合同编号**: [编号]
|
||||||
|
|
||||||
|
**甲方(供货方)**: [公司全称]
|
||||||
|
**乙方(采购方)**: [公司全称]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一条 货物明细
|
||||||
|
|
||||||
|
| 品名 | 规格 | 产地 | 单位 | 数量 | 单价 | 金额 |
|
||||||
|
|------|------|------|------|------|------|------|
|
||||||
|
| | | | | | | |
|
||||||
|
| **合计** | | | | | | |
|
||||||
|
|
||||||
|
## 第二条 质量标准
|
||||||
|
|
||||||
|
2.1 质量标准:[国标/行标/企标/样品]
|
||||||
|
2.2 检验方式:[抽检/全检]
|
||||||
|
|
||||||
|
## 第三条 包装要求
|
||||||
|
|
||||||
|
3.1 包装方式:[描述]
|
||||||
|
3.2 包装费用:[含在货款中/另计]
|
||||||
|
|
||||||
|
## 第四条 交货
|
||||||
|
|
||||||
|
4.1 交货时间:[日期]
|
||||||
|
4.2 交货地点:[地址]
|
||||||
|
4.3 运输方式:[描述]
|
||||||
|
4.4 运费承担:[甲方/乙方]
|
||||||
|
|
||||||
|
## 第五条 价款与支付
|
||||||
|
|
||||||
|
5.1 合同总价:人民币 [金额] 元
|
||||||
|
5.2 付款方式:[预付款比例、货到付款等]
|
||||||
|
5.3 发票类型:[增值税专用发票/普通发票]
|
||||||
|
|
||||||
|
## 第六条 验收与异议
|
||||||
|
|
||||||
|
6.1 验收期限:收货后 [N] 日内
|
||||||
|
6.2 异议期限:发现质量问题后 [N] 日内书面提出
|
||||||
|
|
||||||
|
## 第七条 违约责任
|
||||||
|
|
||||||
|
7.1 甲方逾期交货,每日按合同金额 [0.5]‰ 支付违约金
|
||||||
|
7.2 乙方逾期付款,每日按应付金额 [0.5]‰ 支付违约金
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**甲方(盖章)**: **乙方(盖章)**:
|
||||||
|
|
||||||
|
日期: 日期:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 合同要素检查清单
|
||||||
|
|
||||||
|
撰写合同时,确保包含以下要素:
|
||||||
|
|
||||||
|
### 基本要素
|
||||||
|
- [ ] 合同编号
|
||||||
|
- [ ] 合同标题
|
||||||
|
- [ ] 签约双方信息(全称、代码、地址、法人)
|
||||||
|
- [ ] 签订日期
|
||||||
|
|
||||||
|
### 核心条款
|
||||||
|
- [ ] 标的物/服务内容(明确、具体)
|
||||||
|
- [ ] 数量/规格
|
||||||
|
- [ ] 价款/费用
|
||||||
|
- [ ] 支付方式与时间
|
||||||
|
- [ ] 履行期限/服务期限
|
||||||
|
- [ ] 交付方式/地点
|
||||||
|
|
||||||
|
### 权责条款
|
||||||
|
- [ ] 甲方权利与义务
|
||||||
|
- [ ] 乙方权利与义务
|
||||||
|
- [ ] 验收标准
|
||||||
|
- [ ] 质保条款
|
||||||
|
|
||||||
|
### 风险条款
|
||||||
|
- [ ] 违约责任
|
||||||
|
- [ ] 争议解决方式
|
||||||
|
- [ ] 不可抗力
|
||||||
|
- [ ] 保密条款
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
- [ ] 合同变更与解除
|
||||||
|
- [ ] 通知送达方式
|
||||||
|
- [ ] 合同份数
|
||||||
|
- [ ] 生效条件
|
||||||
|
- [ ] 签字盖章位置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 快速生成合同
|
||||||
|
|
||||||
|
向我描述合同需求,我会帮您生成合同草稿:
|
||||||
|
|
||||||
|
**示例输入**:
|
||||||
|
> 拟定一份软件订阅合同,北京智慧云彩为服务方,重庆妗晨为订阅方,软件是智云物流管理系统,年费2.4万,合同期1年,到期自动续约
|
||||||
|
|
||||||
|
**我会输出**:
|
||||||
|
- 完整的合同文本
|
||||||
|
- 需要补充的信息提示
|
||||||
|
- 风险提示(如有)
|
||||||
|
|
||||||
|
### 合同审核
|
||||||
|
|
||||||
|
您也可以提供合同文本让我审核,我会检查:
|
||||||
|
- 条款完整性
|
||||||
|
- 权责平衡性
|
||||||
|
- 法律风险点
|
||||||
|
- 用语规范性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **本技能生成的合同仅供参考**,重要合同建议经法务/律师审核
|
||||||
|
2. 涉及大额交易、复杂条款的合同,建议专业法律意见
|
||||||
|
3. 请确保公司信息准确无误后再签署
|
||||||
|
4. 合同金额大写与小写应一致
|
||||||
|
5. 注意保留合同签署过程的证据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 思源笔记集成(推荐)
|
||||||
|
|
||||||
|
合同文档优先保存到思源笔记,校对确认后再发布到云文档。
|
||||||
|
|
||||||
|
### 工作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
合同技能生成内容 → 思源笔记(预览/校对) → 飞书云文档(正式发布)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 笔记本配置
|
||||||
|
|
||||||
|
| 配置项 | 值 |
|
||||||
|
|--------|-----|
|
||||||
|
| 笔记本名称 | 商务合同 |
|
||||||
|
| 笔记本ID | `20260202080313-kjtgg1j` |
|
||||||
|
|
||||||
|
### 路径规范
|
||||||
|
|
||||||
|
```
|
||||||
|
/商务合同/
|
||||||
|
├── 物流合同/
|
||||||
|
│ ├── 妗晨-名风-2026
|
||||||
|
│ └── 妗晨-鸿侨-2026
|
||||||
|
├── 软件订阅合同/
|
||||||
|
│ └── 智慧云彩-xxx-2026
|
||||||
|
└── 贸易合同/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
路径格式:`/{合同类型}/{甲方简称}-{乙方简称}-{年份}`
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from siyuan_api import SiYuanAPI
|
||||||
|
|
||||||
|
api = SiYuanAPI()
|
||||||
|
NOTEBOOK_ID = "20260202080313-kjtgg1j"
|
||||||
|
|
||||||
|
# 1. 创建合同到思源笔记
|
||||||
|
contract_md = generate_contract_markdown(...)
|
||||||
|
doc_id = api.upsert_doc(
|
||||||
|
NOTEBOOK_ID,
|
||||||
|
"/物流合同/妗晨-名风-2026",
|
||||||
|
contract_md
|
||||||
|
)
|
||||||
|
print(f"请在思源笔记中校对: {doc_id}")
|
||||||
|
|
||||||
|
# 2. 校对完成后,发布到飞书
|
||||||
|
api.publish_to_feishu(doc_id, "物流服务合同-妗晨与名风")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文档状态标记
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| 📝 草稿 | 初始生成,待校对 |
|
||||||
|
| 🔍 校对中 | 正在审核内容 |
|
||||||
|
| ✅ 已确认 | 校对完成,可发布 |
|
||||||
|
| 🚀 已发布 | 已发布到云文档 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 ai-proj 集成
|
||||||
|
|
||||||
|
合同相关需求可创建到 ai-proj 系统:
|
||||||
|
|
||||||
|
```
|
||||||
|
类型: biz (业务类型)
|
||||||
|
分类: other
|
||||||
|
示例标题: 拟定 XXX 与 YYY 的软件订阅合同
|
||||||
|
```
|
||||||
|
|
||||||
|
合同完成后,可作为文档附件关联到对应需求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 飞书文档集成
|
||||||
|
|
||||||
|
本技能已集成飞书文档,可将合同直接创建到飞书云文档。
|
||||||
|
|
||||||
|
### 权限说明
|
||||||
|
|
||||||
|
**重要**:飞书文档默认对组织内权限是开放的,允许编辑。创建的合同文档在组织内成员均可查看和编辑。如需限制访问权限,请在飞书中手动调整文档权限设置。
|
||||||
|
|
||||||
|
### 可用工具
|
||||||
|
|
||||||
|
| 工具 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `create_document` | 创建空白飞书文档 |
|
||||||
|
| `write_document` | 向已有文档写入内容 |
|
||||||
|
| `create_and_write_document` | 创建文档并写入内容(推荐) |
|
||||||
|
| `get_document_info` | 获取文档信息 |
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
```
|
||||||
|
# 创建合同文档
|
||||||
|
使用 feishu-doc MCP 的 create_and_write_document 工具:
|
||||||
|
- title: "软件订阅服务合同-智慧云彩与妗晨"
|
||||||
|
- content: [Markdown 格式的合同内容]
|
||||||
|
- folder_token: [可选,指定文件夹]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取文件夹 Token
|
||||||
|
|
||||||
|
1. 在飞书中打开目标文件夹
|
||||||
|
2. 复制 URL 中的 folder token(格式如:`fldcnXXXXXX`)
|
||||||
|
3. 调用工具时传入 folder_token 参数
|
||||||
|
|
||||||
|
### 飞书输出格式规范
|
||||||
|
|
||||||
|
#### 开票信息表格
|
||||||
|
|
||||||
|
物流合同中的开票信息应使用**表格**格式展示,列宽配置:
|
||||||
|
|
||||||
|
| 配置项 | 值 | 说明 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| 项目列宽 | 120px | 显示"名称"、"纳税人识别号"等标签 |
|
||||||
|
| 内容列宽 | 480px | 显示具体信息 |
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 创建开票信息表格示例
|
||||||
|
from feishu_docx import FeishuDocx
|
||||||
|
|
||||||
|
docx = FeishuDocx()
|
||||||
|
doc = docx.create_document("物流服务合同")
|
||||||
|
|
||||||
|
# 创建表格(4行2列,指定列宽)
|
||||||
|
table = docx.create_table(doc["document_id"], rows=4, cols=2, col_widths=[120, 480])
|
||||||
|
|
||||||
|
# 填充内容
|
||||||
|
docx.fill_table(doc["document_id"], table, [
|
||||||
|
["名称", "重庆妗晨工贸有限公司"],
|
||||||
|
["纳税人识别号", "91500104MA7EJTPA6D"],
|
||||||
|
["地址、电话", "重庆市大渡口区跳磴镇海康路106号1-1 / 15213397998"],
|
||||||
|
["开户行及账号", "中国农业银行重庆大渡口天安支行 31230401040004386"],
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 签章格式
|
||||||
|
|
||||||
|
推荐使用**独立区块式**签章格式:
|
||||||
|
|
||||||
|
```
|
||||||
|
【甲 方】
|
||||||
|
|
||||||
|
单位名称:重庆妗晨工贸有限公司
|
||||||
|
|
||||||
|
法定代表人(或授权代表): (签字/盖章)
|
||||||
|
|
||||||
|
单位盖章:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
签订日期: 年 月 日
|
||||||
|
|
||||||
|
─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
【乙 方】
|
||||||
|
|
||||||
|
单位名称:北京名风新能源科技有限公司
|
||||||
|
|
||||||
|
法定代表人(或授权代表): (签字/盖章)
|
||||||
|
|
||||||
|
单位盖章:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
签订日期: 年 月 日
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点**:甲乙双方独立区块,留白充足,便于盖章签字。
|
||||||
|
|
||||||
|
#### 物流合同税率
|
||||||
|
|
||||||
|
| 服务类型 | 税率 | 发票类型 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| 交通运输服务 | 9% | 增值税专用发票 |
|
||||||
|
|
||||||
|
#### 默认存储路径
|
||||||
|
|
||||||
|
| 平台 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| 飞书云文档 | ai-proj/01运营 (folder_token: `C80gfkRnzlonQ5d4AhOcOACDnNg`) |
|
||||||
|
| 思源笔记 | 商务合同笔记本 /物流合同/{甲方简称}-{乙方简称}-{年份} |
|
||||||
8
plugins/biz-ops-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/biz-ops-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "biz-ops-plugin",
|
||||||
|
"description": "商务运营技能。支持商业计划书(BP)撰写和商务合同起草。当用户提到商业计划书、BP、融资计划、商业模式、合同、协议、签约等相关任务时自动激活。",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
178
plugins/biz-ops-plugin/skills/SKILL.md
Normal file
178
plugins/biz-ops-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
---
|
||||||
|
name: biz-ops
|
||||||
|
description: 商务运营技能。支持商业计划书(BP)撰写和商务合同起草。当用户提到商业计划书、BP、融资计划、商业模式、合同、协议、签约等相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 商务运营 (biz-ops)
|
||||||
|
|
||||||
|
## 一、商业计划书 (BP)
|
||||||
|
|
||||||
|
### 公司背景
|
||||||
|
|
||||||
|
| 项目 | 描述 |
|
||||||
|
|------|------|
|
||||||
|
| 公司定位 | 酒店供应链系统研发公司 |
|
||||||
|
| 核心能力 | 软件系统研发 + 供应链运营经验 |
|
||||||
|
| 商业模式 | 换房额度 → 供应链平台采购 → 交易佣金 |
|
||||||
|
|
||||||
|
**换房模式**:酒店提供换房额度 → 转化为平台采购额度 → 酒店消费 → 平台抽佣(X%)
|
||||||
|
|
||||||
|
### 标准 BP 结构(10-15页)
|
||||||
|
|
||||||
|
| 章节 | 页数 | 核心内容 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 1. 封面 | 1 | 公司名称、Slogan、联系方式 |
|
||||||
|
| 2. 公司简介 | 1 | 一句话定位、发展历程、里程碑 |
|
||||||
|
| 3. 痛点与机会 | 1-2 | 行业痛点、市场机会、为什么是现在 |
|
||||||
|
| 4. 解决方案 | 2 | 产品/服务介绍、核心功能、价值主张 |
|
||||||
|
| 5. 商业模式 | 1-2 | 如何赚钱、收入来源、定价策略 |
|
||||||
|
| 6. 市场规模 | 1 | TAM/SAM/SOM(酒店采购:TAM 2000亿,SOM 100亿) |
|
||||||
|
| 7. 竞争分析 | 1 | 竞争格局、差异化:软件+供应链双轮 |
|
||||||
|
| 8. 运营数据 | 1-2 | GMV、客户数、复购率、增长曲线 |
|
||||||
|
| 9. 团队介绍 | 1 | 核心成员、背景优势 |
|
||||||
|
| 10. 融资计划 | 1 | 金额、估值、用途(研发40%/市场30%/运营20%/储备10%) |
|
||||||
|
|
||||||
|
### 商业模式收入模型
|
||||||
|
|
||||||
|
| 收入来源 | 说明 |
|
||||||
|
|----------|------|
|
||||||
|
| 交易佣金 | 供应链交易抽佣 (3-8%) |
|
||||||
|
| SaaS 订阅费 | 系统使用年费 |
|
||||||
|
| 金融服务 | 供应链金融分润 |
|
||||||
|
| 增值服务 | 数据服务、营销服务 |
|
||||||
|
|
||||||
|
**Unit Economics**:CAC / LTV(健康值 LTV/CAC >3)/ 毛利率 / 回本周期
|
||||||
|
|
||||||
|
### PPT 输出
|
||||||
|
|
||||||
|
生成路演 PPT 时,输出为单个 HTML 文件(全屏演示):
|
||||||
|
- 支持键盘翻页(← →)、F 全屏、触摸滑动
|
||||||
|
- 配色:深色科技风 `#1a1a2e` 背景 + `#667eea` 主色
|
||||||
|
- 数据卡片 + 两栏布局 + 流程图组件
|
||||||
|
- 保存到桌面后 `open ~/Desktop/bp-presentation.html`
|
||||||
|
|
||||||
|
**PPT 设计原则**:一页一重点 / 大字(标题48px+,正文24px+)/ 数据可视化 / 留白充足
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、商务合同
|
||||||
|
|
||||||
|
### 用户公司信息
|
||||||
|
|
||||||
|
| 公司简称 | 公司全称 | 纳税人识别号 | 地址 | 联系电话 | 法定代表人 |
|
||||||
|
|----------|----------|--------------|------|----------|------------|
|
||||||
|
| 智慧云彩 | 北京智慧云彩电子商务科技有限公司 | 91110114MA004M80XX | 北京市海淀区丰智东路13号院1号楼1层101 | - | - |
|
||||||
|
| 欢乐宿 | 北京欢乐宿供应链科技有限公司 | 91110113MACWJKYU8P | 北京市顺义区军营南街10号院3幢3层327室 | - | - |
|
||||||
|
| 对丝 | 北京对丝信息技术有限公司 | 91110113MAE4XXHR5E | 北京市顺义区军营南街10号院3幢3层3216室 | - | - |
|
||||||
|
| 妗晨 | 重庆妗晨工贸有限公司 | 91500104MA7EJTPA6D | 重庆市大渡口区跳磴镇海康路106号1-1 | 15213397998 | - |
|
||||||
|
| 名风 | 北京名风新能源科技有限公司 | 91110106092440790K | 北京市密云区西田各庄镇雁密路99号601室-3509(集群注册) | - | 魏小健 |
|
||||||
|
| 鸿侨 | 沈阳鸿侨物流有限公司 | 91210113MADBXPU93P | 辽宁省沈阳市沈北新区蒲河路49-2号(1-29-2) | 15567339320 | 禚子乔 |
|
||||||
|
|
||||||
|
### 开户信息
|
||||||
|
|
||||||
|
| 公司简称 | 开户银行 | 银行账号 |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| 智慧云彩 | 招商银行股份有限公司北京立水桥支行 | 110922001210512 |
|
||||||
|
| 欢乐宿 | 中国工商银行北京南十里支行 | 0200206709200036024 |
|
||||||
|
| 对丝 | 招商银行股份有限公司北京顺义支行 | 110961225610001 |
|
||||||
|
| 妗晨 | 中国农业银行股份有限公司重庆大渡口天安支行 | 31230401040004386 |
|
||||||
|
| 名风 | 中国建设银行北京黄亦路支行 | 11001181700052501668 |
|
||||||
|
| 鸿侨 | 中国建设银行股份有限公司沈阳辉山支行 | 21050149004200001104 |
|
||||||
|
|
||||||
|
### 支持的合同类型
|
||||||
|
|
||||||
|
| 类型 | 典型场景 | 税率 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 软件订阅合同 (SaaS) | 云服务、在线系统 | 6% |
|
||||||
|
| 软件定制开发合同 | 项目制开发 | 6% |
|
||||||
|
| 物流服务合同 | 货运、仓储 | **9%**(交通运输服务) |
|
||||||
|
| 销售服务合同 | 商品买卖 | 13% |
|
||||||
|
| 贸易合同 | 批发、进出口 | 13% |
|
||||||
|
| 技术服务合同 | IT 咨询 | 6% |
|
||||||
|
| 保密协议 (NDA) | 合作前签署 | - |
|
||||||
|
|
||||||
|
### 合同模板速查
|
||||||
|
|
||||||
|
#### SaaS 软件订阅合同核心条款
|
||||||
|
|
||||||
|
```
|
||||||
|
第一条 软件服务内容(名称、模块、账号数、SaaS云服务模式)
|
||||||
|
第二条 服务期限(起止日期、到期前30日未异议自动续期)
|
||||||
|
第三条 服务费用与支付(月付/年付标准、付款时间、付款账户)
|
||||||
|
第四条 甲方义务(年可用性≥99.5%、技术支持、数据保密、逾期可暂停服务)
|
||||||
|
第五条 乙方义务(按时付款、禁止转让账号)
|
||||||
|
第六条 数据安全与保密(保密义务合同终止后2年继续有效)
|
||||||
|
第七条 违约责任(违约金=合同总额10%;服务中断>24小时按日退费)
|
||||||
|
第八条 合同变更与解除(乙方提前解约已付费不退;甲方提前解约退剩余)
|
||||||
|
第九条 争议解决(甲方所在地法院)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 软件定制开发合同核心条款
|
||||||
|
|
||||||
|
```
|
||||||
|
第一条 项目内容(名称、开发内容/需求规格说明书、技术要求、交付物清单)
|
||||||
|
第二条 开发周期(总工期、里程碑:需求确认→设计→开发→测试→验收)
|
||||||
|
第三条 付款节点(签约30%→需求确认30%→验收通过40%)
|
||||||
|
第四条 验收标准(收通知后10个工作日内;逾期未验收视为通过)
|
||||||
|
第五条 知识产权(归甲方/乙方/共有)
|
||||||
|
第六条 质保与维护(免费质保期12个月)
|
||||||
|
第七条 违约责任(逾期交付:合同额0.5‰/日;逾期付款:应付额0.5‰/日)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 物流服务合同核心条款
|
||||||
|
|
||||||
|
```
|
||||||
|
税率:9%(交通运输服务增值税专用发票)
|
||||||
|
第一条 运输服务内容(整车/零担/仓储配送、路线、货物类型)
|
||||||
|
第二条 服务期限
|
||||||
|
第三条 运费与结算(计费方式、月结/单结、付款期限)
|
||||||
|
第四条 发票与税务(9%增值税专用发票;开票时间:付款前)
|
||||||
|
第五条/六条 甲乙方权利义务
|
||||||
|
第七条 货损赔偿(实际价值赔偿,最高不超过运费N倍;当场提出异议)
|
||||||
|
```
|
||||||
|
|
||||||
|
**签章格式**(推荐独立区块式):
|
||||||
|
|
||||||
|
```
|
||||||
|
【甲 方】
|
||||||
|
单位名称:
|
||||||
|
法定代表人(或授权代表): (签字/盖章)
|
||||||
|
单位盖章:
|
||||||
|
签订日期: 年 月 日
|
||||||
|
─────────────────────────────────────────────
|
||||||
|
【乙 方】
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 合同要素检查清单
|
||||||
|
|
||||||
|
- [ ] 合同编号、标题、签订日期
|
||||||
|
- [ ] 双方全称、统一社会信用代码、地址、法人
|
||||||
|
- [ ] 标的物/服务内容(明确具体)
|
||||||
|
- [ ] 价款、支付方式与时间
|
||||||
|
- [ ] 履行期限/交付方式
|
||||||
|
- [ ] 验收标准、质保条款
|
||||||
|
- [ ] 违约责任(违约金比例)
|
||||||
|
- [ ] 争议解决方式
|
||||||
|
- [ ] 不可抗力、保密条款
|
||||||
|
- [ ] 合同份数、生效条件
|
||||||
|
|
||||||
|
### 文档存储
|
||||||
|
|
||||||
|
| 平台 | 路径/Token |
|
||||||
|
|------|------------|
|
||||||
|
| 飞书云文档 | ai-proj/01运营 (`C80gfkRnzlonQ5d4AhOcOACDnNg`) |
|
||||||
|
| 思源笔记 | 商务合同笔记本 ID: `20260202080313-kjtgg1j` |
|
||||||
|
|
||||||
|
思源路径规范:`/{合同类型}/{甲方简称}-{乙方简称}-{年份}`
|
||||||
|
|
||||||
|
工作流:合同技能生成内容 → 思源笔记(校对)→ 飞书云文档(正式发布)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. BP 数据必须真实可查证,敏感数据可用 [X] 占位
|
||||||
|
2. 合同金额大写与小写必须一致
|
||||||
|
3. **本技能生成的合同仅供参考**,重要合同建议经法务/律师审核
|
||||||
|
4. 公司信息确认无误后再签署
|
||||||
8
plugins/biz-plan-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/biz-plan-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "biz-plan-plugin",
|
||||||
|
"description": "Plugin for biz-plan",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
760
plugins/biz-plan-plugin/skills/SKILL.md
Normal file
760
plugins/biz-plan-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
---
|
||||||
|
name: biz-plan
|
||||||
|
description: 商业计划书撰写。支持融资BP、战略规划、商业模式设计等。当用户提到商业计划书、BP、融资计划、商业模式、战略规划相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 商业计划书撰写技能 (biz-plan)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本技能用于辅助商业计划书的撰写与优化,特别适用于 B2B SaaS、供应链平台、产业互联网等领域。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 公司背景(可定制)
|
||||||
|
|
||||||
|
### 核心信息
|
||||||
|
|
||||||
|
| 项目 | 描述 |
|
||||||
|
|------|------|
|
||||||
|
| 公司定位 | 酒店供应链系统研发公司 |
|
||||||
|
| 发展阶段 | 多轮融资,成熟期 |
|
||||||
|
| 核心能力 | 软件系统研发 + 供应链运营经验 |
|
||||||
|
| 资源优势 | 头部酒店集团资源 |
|
||||||
|
|
||||||
|
### 商业模式
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 平台商业模式 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ 换房额度 ┌──────────────┐ │
|
||||||
|
│ │ 酒店端 │ ──────────────────→ │ 供应链平台 │ │
|
||||||
|
│ │ │ ←────────────────── │ (集团总部) │ │
|
||||||
|
│ └──────────┘ 采购消费 └──────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 房间资源 │ 商品/服务 │
|
||||||
|
│ ↓ ↓ │
|
||||||
|
│ ┌──────────┐ ┌──────────────┐ │
|
||||||
|
│ │ 换房用户 │ │ 供应商 │ │
|
||||||
|
│ └──────────┘ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 收入来源: 供应链交易服务佣金 │
|
||||||
|
│ 核心价值: 软件系统增强上下游粘性 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 商业计划书结构
|
||||||
|
|
||||||
|
### 标准融资 BP 结构(10-15页)
|
||||||
|
|
||||||
|
| 章节 | 页数 | 核心内容 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 1. 封面 | 1 | 公司名称、Slogan、联系方式 |
|
||||||
|
| 2. 公司简介 | 1 | 一句话定位、发展历程、里程碑 |
|
||||||
|
| 3. 痛点与机会 | 1-2 | 行业痛点、市场机会、为什么是现在 |
|
||||||
|
| 4. 解决方案 | 2 | 产品/服务介绍、核心功能、价值主张 |
|
||||||
|
| 5. 商业模式 | 1-2 | 如何赚钱、收入来源、定价策略 |
|
||||||
|
| 6. 市场规模 | 1 | TAM/SAM/SOM、增长趋势 |
|
||||||
|
| 7. 竞争分析 | 1 | 竞争格局、差异化优势 |
|
||||||
|
| 8. 运营数据 | 1-2 | 关键指标、增长曲线、客户案例 |
|
||||||
|
| 9. 团队介绍 | 1 | 核心成员、背景优势 |
|
||||||
|
| 10. 融资计划 | 1 | 融资金额、估值、资金用途 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 各章节撰写指南
|
||||||
|
|
||||||
|
### 1. 封面
|
||||||
|
|
||||||
|
```
|
||||||
|
[公司 LOGO]
|
||||||
|
|
||||||
|
公司名称
|
||||||
|
——————————————
|
||||||
|
一句话 Slogan(不超过20字)
|
||||||
|
|
||||||
|
联系人:XXX
|
||||||
|
电话:XXX
|
||||||
|
邮箱:XXX
|
||||||
|
|
||||||
|
[日期]
|
||||||
|
[保密声明]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 公司简介
|
||||||
|
|
||||||
|
**一句话定位模板**:
|
||||||
|
> 我们是 [行业] 领域的 [产品类型],通过 [核心能力],帮助 [目标客户] 实现 [核心价值]。
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
> 我们是酒店供应链领域的产业互联网平台,通过 SaaS 系统 + 供应链服务,帮助酒店集团降低采购成本、提升运营效率。
|
||||||
|
|
||||||
|
**发展历程**:
|
||||||
|
| 时间 | 里程碑 |
|
||||||
|
|------|--------|
|
||||||
|
| 20XX年 | 公司成立,获得天使轮融资 |
|
||||||
|
| 20XX年 | 产品上线,首批客户签约 |
|
||||||
|
| 20XX年 | A轮融资,GMV突破X亿 |
|
||||||
|
| 20XX年 | 签约X家头部酒店集团 |
|
||||||
|
|
||||||
|
### 3. 痛点与机会
|
||||||
|
|
||||||
|
**痛点分析框架**:
|
||||||
|
|
||||||
|
```
|
||||||
|
行业痛点
|
||||||
|
├── 酒店端痛点
|
||||||
|
│ ├── 采购成本高、供应商分散
|
||||||
|
│ ├── 库存管理粗放
|
||||||
|
│ └── 缺乏数字化工具
|
||||||
|
├── 供应商端痛点
|
||||||
|
│ ├── 获客成本高
|
||||||
|
│ ├── 账期长、现金流压力
|
||||||
|
│ └── 缺乏稳定渠道
|
||||||
|
└── 行业痛点
|
||||||
|
├── 信息不对称
|
||||||
|
├── 交易效率低
|
||||||
|
└── 缺乏标准化
|
||||||
|
```
|
||||||
|
|
||||||
|
**市场机会**:
|
||||||
|
- 政策利好:数字化转型、供应链金融政策
|
||||||
|
- 技术成熟:SaaS、移动互联网、大数据
|
||||||
|
- 行业变革:酒店行业整合、集团化趋势
|
||||||
|
|
||||||
|
### 4. 解决方案
|
||||||
|
|
||||||
|
**产品矩阵**:
|
||||||
|
|
||||||
|
| 产品 | 目标用户 | 核心功能 | 价值 |
|
||||||
|
|------|----------|----------|------|
|
||||||
|
| 酒店采购系统 | 酒店采购部 | 在线下单、供应商管理、成本分析 | 降本30% |
|
||||||
|
| 供应商平台 | 供应商 | 商品管理、订单处理、对账结算 | 获客成本降低50% |
|
||||||
|
| 集团管控系统 | 酒店集团 | 集采管理、数据分析、合规审计 | 管理效率提升 |
|
||||||
|
| 换房服务平台 | 企业/个人 | 房间预订、积分兑换 | 差旅成本优化 |
|
||||||
|
|
||||||
|
**技术架构**(简化版):
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 业务中台 │
|
||||||
|
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||||
|
│ │订单 │ │商品 │ │用户 │ │结算 │ │
|
||||||
|
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 数据中台 │
|
||||||
|
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||||
|
│ │BI │ │算法 │ │数仓 │ │
|
||||||
|
│ └─────┘ └─────┘ └─────┘ │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 商业模式
|
||||||
|
|
||||||
|
**收入模型**:
|
||||||
|
|
||||||
|
| 收入来源 | 说明 | 占比 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 交易佣金 | 供应链交易抽佣 (X%) | XX% |
|
||||||
|
| SaaS 订阅费 | 系统使用年费 | XX% |
|
||||||
|
| 金融服务 | 供应链金融分润 | XX% |
|
||||||
|
| 增值服务 | 数据服务、营销服务 | XX% |
|
||||||
|
|
||||||
|
**单位经济模型**(Unit Economics):
|
||||||
|
| 指标 | 数值 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| CAC | ¥XXX | 获客成本 |
|
||||||
|
| LTV | ¥XXX | 客户生命周期价值 |
|
||||||
|
| LTV/CAC | X.X | 健康值 >3 |
|
||||||
|
| 毛利率 | XX% | |
|
||||||
|
| 回本周期 | X个月 | |
|
||||||
|
|
||||||
|
### 6. 市场规模
|
||||||
|
|
||||||
|
**TAM/SAM/SOM 分析**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────┐
|
||||||
|
│ TAM: XXX亿 │
|
||||||
|
│ (Total Addressable Market) │
|
||||||
|
│ ┌────────────────────────────┐ │
|
||||||
|
│ │ SAM: XXX亿 │ │
|
||||||
|
│ │ (Serviceable Addressable) │ │
|
||||||
|
│ │ ┌──────────────────┐ │ │
|
||||||
|
│ │ │ SOM: XXX亿 │ │ │
|
||||||
|
│ │ │ (Serviceable │ │ │
|
||||||
|
│ │ │ Obtainable) │ │ │
|
||||||
|
│ │ └──────────────────┘ │ │
|
||||||
|
│ └────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**酒店供应链市场规模**(示例):
|
||||||
|
- 中国酒店行业规模:约 6000 亿
|
||||||
|
- 酒店采购市场(TAM):约 2000 亿
|
||||||
|
- 连锁/集团酒店采购(SAM):约 800 亿
|
||||||
|
- 可触达市场(SOM):约 100 亿
|
||||||
|
|
||||||
|
### 7. 竞争分析
|
||||||
|
|
||||||
|
**竞争格局**:
|
||||||
|
|
||||||
|
| 类型 | 代表企业 | 优势 | 劣势 |
|
||||||
|
|------|----------|------|------|
|
||||||
|
| 传统供应商 | XX、YY | 客户关系、线下服务 | 数字化能力弱 |
|
||||||
|
| SaaS 厂商 | AA、BB | 技术能力强 | 缺乏供应链能力 |
|
||||||
|
| 互联网平台 | CC、DD | 流量、资金 | 行业理解不深 |
|
||||||
|
| **我们** | - | 软件+供应链双轮驱动 | - |
|
||||||
|
|
||||||
|
**差异化优势**:
|
||||||
|
1. **行业 Know-how**:多年酒店供应链经验
|
||||||
|
2. **头部资源**:XX家头部集团客户
|
||||||
|
3. **创新模式**:换房模式增强粘性
|
||||||
|
4. **技术壁垒**:自研系统,快速迭代
|
||||||
|
|
||||||
|
### 8. 运营数据
|
||||||
|
|
||||||
|
**关键指标**:
|
||||||
|
|
||||||
|
| 指标 | 2023 | 2024 | 2025E | YoY增长 |
|
||||||
|
|------|------|------|-------|---------|
|
||||||
|
| GMV(亿) | X | X | X | XX% |
|
||||||
|
| 收入(万) | X | X | X | XX% |
|
||||||
|
| 客户数 | X | X | X | XX% |
|
||||||
|
| 复购率 | X% | X% | X% | - |
|
||||||
|
|
||||||
|
**增长曲线**(用文字描述,实际 BP 用图表):
|
||||||
|
- 2022-2023:产品打磨期,种子客户验证
|
||||||
|
- 2024:规模扩张期,客户数 X 倍增长
|
||||||
|
- 2025:加速增长期,目标 GMV XX亿
|
||||||
|
|
||||||
|
**标杆客户案例**:
|
||||||
|
> **XX酒店集团**(X00家酒店)
|
||||||
|
> - 合作前:采购分散,年采购成本 X 亿
|
||||||
|
> - 合作后:统一平台采购,成本降低 XX%
|
||||||
|
> - 换房模式:年换房 X 万间夜,转化采购额 XX 万
|
||||||
|
|
||||||
|
### 9. 团队介绍
|
||||||
|
|
||||||
|
**核心团队**:
|
||||||
|
|
||||||
|
| 姓名 | 职位 | 背景 |
|
||||||
|
|------|------|------|
|
||||||
|
| XXX | CEO | XX酒店集团XX年,XX大学MBA |
|
||||||
|
| XXX | CTO | 原XX公司技术总监,XX年技术经验 |
|
||||||
|
| XXX | COO | 原XX供应链公司,XX年运营经验 |
|
||||||
|
| XXX | CFO | 原XX投资,XX年财务经验 |
|
||||||
|
|
||||||
|
**团队优势**:
|
||||||
|
- 酒店行业背景:平均 X 年行业经验
|
||||||
|
- 技术能力:XX 人研发团队
|
||||||
|
- 资源网络:覆盖 XX% 头部酒店集团
|
||||||
|
|
||||||
|
### 10. 融资计划
|
||||||
|
|
||||||
|
**本轮融资**:
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 融资轮次 | X 轮 |
|
||||||
|
| 融资金额 | 人民币 XXX 万元 |
|
||||||
|
| 出让股份 | XX% |
|
||||||
|
| 投前估值 | 人民币 XXX 万元 |
|
||||||
|
| 资金用途 | 见下表 |
|
||||||
|
|
||||||
|
**资金用途**:
|
||||||
|
|
||||||
|
| 用途 | 占比 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 产品研发 | 40% | 核心系统升级、AI 能力建设 |
|
||||||
|
| 市场拓展 | 30% | 销售团队扩张、客户获取 |
|
||||||
|
| 运营投入 | 20% | 供应链能力建设、服务体系 |
|
||||||
|
| 储备资金 | 10% | 现金流储备 |
|
||||||
|
|
||||||
|
**里程碑规划**:
|
||||||
|
|
||||||
|
| 时间 | 目标 |
|
||||||
|
|------|------|
|
||||||
|
| 融资后6个月 | 客户数达到 X 家,GMV XX亿 |
|
||||||
|
| 融资后12个月 | 新产品上线,进入 X 个新区域 |
|
||||||
|
| 融资后18个月 | 实现盈亏平衡 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 行业专项模板
|
||||||
|
|
||||||
|
### 酒店供应链 BP 要点
|
||||||
|
|
||||||
|
**换房模式说明**:
|
||||||
|
|
||||||
|
```
|
||||||
|
换房模式运作逻辑
|
||||||
|
─────────────────────────────────────────
|
||||||
|
|
||||||
|
1. 酒店提供房间资源
|
||||||
|
酒店 → 平台:承诺年度换房额度(如100万间夜)
|
||||||
|
|
||||||
|
2. 平台转化为采购额度
|
||||||
|
换房价值 → 按比例转化为供应链采购额度
|
||||||
|
|
||||||
|
3. 酒店在平台消费
|
||||||
|
酒店使用额度 → 在供应链平台采购商品/服务
|
||||||
|
|
||||||
|
4. 平台获取佣金
|
||||||
|
供应链交易 → 平台抽取 X% 服务佣金
|
||||||
|
|
||||||
|
核心价值:
|
||||||
|
✓ 酒店:用闲置资源换取采购成本优化
|
||||||
|
✓ 平台:获得稳定交易额 + 服务佣金
|
||||||
|
✓ 供应商:获得优质渠道 + 稳定订单
|
||||||
|
```
|
||||||
|
|
||||||
|
### 供应链平台指标体系
|
||||||
|
|
||||||
|
| 指标类别 | 指标名称 | 说明 | 行业基准 |
|
||||||
|
|----------|----------|------|----------|
|
||||||
|
| 规模指标 | GMV | 平台交易总额 | - |
|
||||||
|
| | 活跃客户数 | 月活跃采购客户 | - |
|
||||||
|
| | SKU 数量 | 商品种类数 | - |
|
||||||
|
| 效率指标 | 订单转化率 | 询价到下单转化 | >15% |
|
||||||
|
| | 履约率 | 按时交付率 | >95% |
|
||||||
|
| | 客诉率 | 投诉订单占比 | <1% |
|
||||||
|
| 财务指标 | 佣金率 | 平台抽佣比例 | 3-8% |
|
||||||
|
| | 毛利率 | | 30-50% |
|
||||||
|
| | CAC Payback | 获客成本回收期 | <12月 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 快速生成 BP
|
||||||
|
|
||||||
|
向我描述你的需求,我会帮你生成商业计划书内容:
|
||||||
|
|
||||||
|
**示例输入**:
|
||||||
|
> 帮我写商业计划书第5章商业模式部分,我们是酒店供应链平台,主要收入来源是交易佣金和SaaS订阅费,另外还有换房模式
|
||||||
|
|
||||||
|
**我会输出**:
|
||||||
|
- 结构化的商业模式描述
|
||||||
|
- 收入模型分析
|
||||||
|
- 需要补充的数据清单
|
||||||
|
|
||||||
|
### BP 审核优化
|
||||||
|
|
||||||
|
你可以提供已有的 BP 内容让我帮你:
|
||||||
|
- 检查逻辑完整性
|
||||||
|
- 优化表达方式
|
||||||
|
- 补充行业数据
|
||||||
|
- 突出差异化优势
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
### 支持的输出方式
|
||||||
|
|
||||||
|
| 方式 | 工具 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Markdown | 直接输出 | 快速预览 |
|
||||||
|
| 飞书文档 | feishu skill | 在线协作 |
|
||||||
|
| Word/PDF | docx/pdf skill | 正式文件 |
|
||||||
|
| PPT | pptx skill | 路演演示 |
|
||||||
|
|
||||||
|
### 融资路演 PPT 结构
|
||||||
|
|
||||||
|
如需生成融资路演 PPT,建议使用以下结构:
|
||||||
|
|
||||||
|
1. 封面(公司名+Slogan)
|
||||||
|
2. 问题(行业痛点)
|
||||||
|
3. 方案(产品价值)
|
||||||
|
4. 市场(规模机会)
|
||||||
|
5. 产品(功能展示)
|
||||||
|
6. 模式(如何赚钱)
|
||||||
|
7. 数据(业绩证明)
|
||||||
|
8. 竞争(差异优势)
|
||||||
|
9. 团队(核心成员)
|
||||||
|
10. 融资(计划用途)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **数据真实性**:BP 中的数据必须真实可查证
|
||||||
|
2. **保密性**:敏感数据可用 X 代替
|
||||||
|
3. **针对性**:根据投资人偏好调整侧重点
|
||||||
|
4. **简洁性**:每页一个核心信息点
|
||||||
|
5. **视觉化**:用图表代替大段文字
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与其他技能协同
|
||||||
|
|
||||||
|
| 技能 | 协同场景 |
|
||||||
|
|------|----------|
|
||||||
|
| biz-contract | 融资后签署投资协议 |
|
||||||
|
| feishu | 将 BP 保存到飞书文档 |
|
||||||
|
| data-excel | 财务模型和数据分析 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PPT 设计能力
|
||||||
|
|
||||||
|
本技能支持生成商业计划书 PPT,输出为 HTML 格式的演示文稿,可直接在浏览器中全屏演示,也可转换为 PDF 或截图使用。
|
||||||
|
|
||||||
|
### PPT 输出方式
|
||||||
|
|
||||||
|
当用户要求生成 PPT 时,使用以下方式:
|
||||||
|
|
||||||
|
1. **HTML 演示文稿**(推荐)
|
||||||
|
- 生成单个 HTML 文件
|
||||||
|
- 支持全屏演示(按 F 键)
|
||||||
|
- 支持键盘翻页(← →)
|
||||||
|
- 可直接打印为 PDF
|
||||||
|
- 响应式设计,适配各种屏幕
|
||||||
|
|
||||||
|
2. **Markdown 演示脚本**
|
||||||
|
- 输出每页内容的文字稿
|
||||||
|
- 用户可自行复制到 PPT 软件
|
||||||
|
|
||||||
|
### HTML PPT 模板
|
||||||
|
|
||||||
|
生成 PPT 时使用以下 HTML 模板结构:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!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 {
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide {
|
||||||
|
display: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 60px 80px;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 封面样式 */
|
||||||
|
.slide.cover {
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide.cover h1 {
|
||||||
|
font-size: 72px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide.cover .slogan {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #a0aec0;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide.cover .info {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容页样式 */
|
||||||
|
.slide h2 {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide h3 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide p, .slide li {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide ul li {
|
||||||
|
padding: 12px 0;
|
||||||
|
padding-left: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide ul li::before {
|
||||||
|
content: "▸";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数据卡片 */
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .value {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .label {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #a0aec0;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 两栏布局 */
|
||||||
|
.two-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 60px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流程图 */
|
||||||
|
.flow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-item {
|
||||||
|
background: rgba(102, 126, 234, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-arrow {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页码 */
|
||||||
|
.page-number {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条 */
|
||||||
|
.progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||||
|
transition: width 0.3s;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航提示 */
|
||||||
|
.nav-hint {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="progress" id="progress"></div>
|
||||||
|
|
||||||
|
<!-- Slide 1: 封面 -->
|
||||||
|
<div class="slide cover active">
|
||||||
|
<h1>[公司名称]</h1>
|
||||||
|
<p class="slogan">[一句话 Slogan]</p>
|
||||||
|
<p class="info">[日期] · 商业计划书</p>
|
||||||
|
<span class="page-number">1 / N</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide 2: 公司定位 -->
|
||||||
|
<div class="slide">
|
||||||
|
<h2>公司定位</h2>
|
||||||
|
<h3>[一句话定位]</h3>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="value">[X]年</div>
|
||||||
|
<div class="label">深耕行业</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="value">[X]家</div>
|
||||||
|
<div class="label">头部客户</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="value">[X]轮</div>
|
||||||
|
<div class="label">完成融资</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="page-number">2 / N</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 更多 slides... -->
|
||||||
|
|
||||||
|
<p class="nav-hint">← → 翻页 · F 全屏 · ESC 退出</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const slides = document.querySelectorAll('.slide');
|
||||||
|
const progress = document.getElementById('progress');
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
function showSlide(n) {
|
||||||
|
slides[current].classList.remove('active');
|
||||||
|
current = (n + slides.length) % slides.length;
|
||||||
|
slides[current].classList.add('active');
|
||||||
|
progress.style.width = ((current + 1) / slides.length * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === ' ') showSlide(current + 1);
|
||||||
|
if (e.key === 'ArrowLeft') showSlide(current - 1);
|
||||||
|
if (e.key === 'f' || e.key === 'F') {
|
||||||
|
document.documentElement.requestFullscreen?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触摸滑动支持
|
||||||
|
let touchStartX = 0;
|
||||||
|
document.addEventListener('touchstart', e => touchStartX = e.touches[0].clientX);
|
||||||
|
document.addEventListener('touchend', e => {
|
||||||
|
const diff = e.changedTouches[0].clientX - touchStartX;
|
||||||
|
if (Math.abs(diff) > 50) showSlide(current + (diff > 0 ? -1 : 1));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### BP PPT 标准页面结构
|
||||||
|
|
||||||
|
生成商业计划书 PPT 时,按以下结构组织:
|
||||||
|
|
||||||
|
| 页码 | 页面类型 | 内容要点 | 设计建议 |
|
||||||
|
|------|----------|----------|----------|
|
||||||
|
| 1 | 封面 | 公司名、Slogan、日期 | 居中大标题,渐变背景 |
|
||||||
|
| 2 | 公司定位 | 一句话定位 + 核心数据 | 3-4 个数据卡片 |
|
||||||
|
| 3 | 行业痛点 | 3-4 个核心痛点 | 图标 + 简短描述 |
|
||||||
|
| 4 | 解决方案 | 产品价值主张 | 流程图或产品矩阵 |
|
||||||
|
| 5 | 商业模式 | 收入来源、盈利方式 | 模式图 + 收入占比 |
|
||||||
|
| 6 | 市场规模 | TAM/SAM/SOM | 同心圆图 + 数字 |
|
||||||
|
| 7 | 竞争分析 | 竞争矩阵、差异化 | 2x2 矩阵图 |
|
||||||
|
| 8 | 运营数据 | 核心指标、增长趋势 | 4 个大数字卡片 |
|
||||||
|
| 9 | 客户案例 | 标杆客户、成果 | Logo + 数据对比 |
|
||||||
|
| 10 | 团队介绍 | 核心成员背景 | 头像 + 简介 |
|
||||||
|
| 11 | 里程碑 | 发展历程 | 时间线 |
|
||||||
|
| 12 | 财务预测 | 3 年收入预测 | 柱状图示意 |
|
||||||
|
| 13 | 战略规划 | 未来规划 | 阶段性目标 |
|
||||||
|
| 14 | 融资计划 | 金额、用途、联系方式 | 饼图 + 联系信息 |
|
||||||
|
|
||||||
|
### PPT 设计原则
|
||||||
|
|
||||||
|
1. **一页一重点**:每页只传达一个核心信息
|
||||||
|
2. **大字少字**:标题 48px+,正文 24px+,每页文字不超过 50 字
|
||||||
|
3. **数据可视化**:用数字卡片、图表代替文字描述
|
||||||
|
4. **留白充足**:内容不要铺满,保持呼吸感
|
||||||
|
5. **配色统一**:使用 2-3 种主色,保持一致性
|
||||||
|
6. **高对比度**:深色背景 + 浅色文字,或浅色背景 + 深色文字
|
||||||
|
|
||||||
|
### 配色方案
|
||||||
|
|
||||||
|
**深色科技风**(推荐):
|
||||||
|
- 背景:#1a1a2e → #16213e(渐变)
|
||||||
|
- 主色:#667eea(蓝紫色)
|
||||||
|
- 辅助:#764ba2(紫色)
|
||||||
|
- 文字:#e2e8f0(浅灰)
|
||||||
|
- 次要文字:#a0aec0(中灰)
|
||||||
|
|
||||||
|
**浅色商务风**:
|
||||||
|
- 背景:#ffffff
|
||||||
|
- 主色:#2196F3(蓝色)
|
||||||
|
- 辅助:#1976D2(深蓝)
|
||||||
|
- 文字:#212121(深灰)
|
||||||
|
- 次要文字:#616161(中灰)
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
用户请求:
|
||||||
|
> 帮我生成商业计划书 PPT
|
||||||
|
|
||||||
|
输出方式:
|
||||||
|
1. 生成完整的 HTML 文件
|
||||||
|
2. 保存到用户指定位置(如桌面、项目目录)
|
||||||
|
3. 用户用浏览器打开即可演示
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 打开 PPT
|
||||||
|
open ~/Desktop/bp-presentation.html
|
||||||
|
|
||||||
|
# 或在浏览器中打开后按 F 进入全屏模式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 导出为 PDF
|
||||||
|
|
||||||
|
HTML PPT 可通过浏览器打印功能导出为 PDF:
|
||||||
|
1. 在浏览器中打开 HTML 文件
|
||||||
|
2. 按 Cmd+P (Mac) 或 Ctrl+P (Windows)
|
||||||
|
3. 选择"另存为 PDF"
|
||||||
|
4. 设置页面大小为横向 A4 或 16:9
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
1. **内容优先**:先确定 BP 内容,再生成 PPT
|
||||||
|
2. **数据占位**:敏感数据可用 [X] 占位
|
||||||
|
3. **迭代优化**:可分页生成,逐步完善
|
||||||
|
4. **本地演示**:HTML 文件完全离线可用,无需网络
|
||||||
8
plugins/coolbuy-legacy-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/coolbuy-legacy-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "coolbuy-legacy-plugin",
|
||||||
|
"description": "酷采2.0团购管理系统测试与维护。用于酷采2.0系统的功能测试、问题排查、需求验证和对比测试。",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
414
plugins/coolbuy-legacy-plugin/skills/SKILL.md
Normal file
414
plugins/coolbuy-legacy-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
---
|
||||||
|
name: coolbuy-legacy
|
||||||
|
description: 酷采2.0团购管理系统测试与维护。用于酷采2.0系统的功能测试、问题排查、需求验证和对比测试。当用户提到酷采2.0、百丽、李宁、遗留系统测试相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Coolbuy Legacy (酷采2.0) Skill
|
||||||
|
|
||||||
|
酷采2.0团购管理系统,服务于百丽、李宁等客户的遗留系统,采用 Vue 2 + Element UI + Java Spring 技术栈。
|
||||||
|
|
||||||
|
## 项目信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 项目编号 | P264 |
|
||||||
|
| 项目名称 | 酷采 2.0 |
|
||||||
|
| AI-Proj 项目ID | 164 |
|
||||||
|
| 源码路径 | `/Users/donglinlai/workspace/coolbuy-legacy` |
|
||||||
|
| Git 仓库 | `git@gitea.pipexerp.com:pipexerp/coolbuy-legacy.git` |
|
||||||
|
| 主分支 | main |
|
||||||
|
| 技术栈 | Vue 2 + Element UI + Java Spring |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统访问
|
||||||
|
|
||||||
|
### 测试环境
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 测试地址 | http://47.105.185.154:9300/login |
|
||||||
|
| 管理员账号 | 19090009801 |
|
||||||
|
| 密码 | 123456 |
|
||||||
|
| 客户账号 | 17761202551 / 202551 |
|
||||||
|
| 服务器 | 47.105.185.154 |
|
||||||
|
|
||||||
|
### 主要客户
|
||||||
|
|
||||||
|
- **百丽集团** - 大型鞋业零售集团
|
||||||
|
- **李宁体育** - 知名体育用品品牌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
coolbuy-legacy/
|
||||||
|
├── cool_lining/module-provider/ # Java 后端服务
|
||||||
|
│ └── src/main/java/com/jzg/module/
|
||||||
|
│ ├── action/ # 控制器层
|
||||||
|
│ │ ├── prd/ # 商品模块控制器
|
||||||
|
│ │ ├── order/ # 订单模块控制器
|
||||||
|
│ │ └── customer/ # 客户模块控制器
|
||||||
|
│ ├── dao/model/ # 数据模型
|
||||||
|
│ │ ├── prd/ # 商品实体
|
||||||
|
│ │ ├── order/ # 订单实体
|
||||||
|
│ │ └── customer/ # 客户实体
|
||||||
|
│ └── manager/ # 业务逻辑层
|
||||||
|
│ ├── prd/ # 商品业务逻辑
|
||||||
|
│ ├── order/ # 订单业务逻辑
|
||||||
|
│ └── customer/ # 客户业务逻辑
|
||||||
|
└── ln_admin/ # Vue 2 前端
|
||||||
|
└── src/views2/module/ # 业务模块页面
|
||||||
|
├── prd/ # 商品管理页面
|
||||||
|
├── order/ # 订单管理页面
|
||||||
|
└── customer/ # 客户管理页面
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主要功能模块
|
||||||
|
|
||||||
|
### 核心业务模块
|
||||||
|
|
||||||
|
1. **推广方案管理**
|
||||||
|
- 促销活动配置
|
||||||
|
- 折扣规则设置
|
||||||
|
- 活动效果统计
|
||||||
|
|
||||||
|
2. **销售管理**
|
||||||
|
- 订单处理流程
|
||||||
|
- 销售数据统计
|
||||||
|
- 客户下单管理
|
||||||
|
|
||||||
|
3. **草稿单管理**
|
||||||
|
- 未完成订单保存
|
||||||
|
- 草稿单编辑
|
||||||
|
- 批量转正式单
|
||||||
|
|
||||||
|
4. **Y码直客**
|
||||||
|
- 直客订单管理
|
||||||
|
- Y码生成与核销
|
||||||
|
- 直客价格体系
|
||||||
|
|
||||||
|
5. **库存管理**
|
||||||
|
- 库存查询
|
||||||
|
- 库存调拨
|
||||||
|
- 库存预警
|
||||||
|
|
||||||
|
6. **货盘管理**
|
||||||
|
- 货盘创建
|
||||||
|
- 货盘分配
|
||||||
|
- 货盘跟踪
|
||||||
|
|
||||||
|
7. **协同仓管理**
|
||||||
|
- 多仓协同
|
||||||
|
- 仓库调度
|
||||||
|
- 发货管理
|
||||||
|
|
||||||
|
8. **价格管理**
|
||||||
|
- 商品定价
|
||||||
|
- 客户价格体系
|
||||||
|
- 最低折扣限制
|
||||||
|
|
||||||
|
9. **资金管理**
|
||||||
|
- 账户余额
|
||||||
|
- 充值记录
|
||||||
|
- 消费明细
|
||||||
|
|
||||||
|
10. **产品管理** ⭐
|
||||||
|
- 商品信息维护
|
||||||
|
- SPU/SKU管理
|
||||||
|
- 商品分类
|
||||||
|
|
||||||
|
11. **基础功能**
|
||||||
|
- 客户管理
|
||||||
|
- 用户权限
|
||||||
|
- 系统配置
|
||||||
|
|
||||||
|
12. **数据看板**
|
||||||
|
- 销售数据分析
|
||||||
|
- 库存报表
|
||||||
|
- 业务概览
|
||||||
|
|
||||||
|
13. **公告通知**
|
||||||
|
- 系统公告
|
||||||
|
- 消息推送
|
||||||
|
- 通知管理
|
||||||
|
|
||||||
|
14. **起售数量设置**
|
||||||
|
- 最小起售量
|
||||||
|
- 批量设置
|
||||||
|
- 规则配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与酷采3.0的对比
|
||||||
|
|
||||||
|
### 技术栈差异
|
||||||
|
|
||||||
|
| 项目 | 酷采2.0 (Legacy) | 酷采3.0 (PaaS) |
|
||||||
|
|------|------------------|----------------|
|
||||||
|
| 前端框架 | Vue 2 | React 18 |
|
||||||
|
| UI组件库 | Element UI | Ant Design |
|
||||||
|
| 前端构建 | Webpack | Vite |
|
||||||
|
| 后端语言 | Java | Go |
|
||||||
|
| 后端框架 | Spring Boot | Gin + go-zero |
|
||||||
|
| 数据库 | MySQL | PostgreSQL |
|
||||||
|
| 架构模式 | 单体应用 | 微服务 |
|
||||||
|
| 部署方式 | 传统部署 | Docker + K8s |
|
||||||
|
|
||||||
|
### 业务差异
|
||||||
|
|
||||||
|
| 功能 | 酷采2.0 | 酷采3.0 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 多租户 | ❌ 单租户 | ✅ 多租户 SaaS |
|
||||||
|
| 客户隔离 | 账号级别 | 企业级别 |
|
||||||
|
| 定制化 | 客户专属部署 | 配置化租户 |
|
||||||
|
| 扩展性 | 垂直扩展 | 水平扩展 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试任务管理
|
||||||
|
|
||||||
|
### AI-Proj 项目集成
|
||||||
|
|
||||||
|
当前项目在 AI-Proj 系统中的ID为 **164**,包含以下测试任务:
|
||||||
|
|
||||||
|
#### 客户最低折扣申请限制功能测试
|
||||||
|
|
||||||
|
**父任务**: #4725 (in_progress)
|
||||||
|
|
||||||
|
**测试用例**:
|
||||||
|
- ✅ TC001: 按客户类型配置最低折扣 [P0] - #4726
|
||||||
|
- ✅ TC002: 按特定客户配置最低折扣 [P0] - #4727
|
||||||
|
- ✅ TC003: 折扣下限输入验证 [P1] - #4728
|
||||||
|
- ✅ TC004: 阈值导入模板 [P1] - #4729
|
||||||
|
- ✅ TC005: 折扣低于下限 - 拦截 [P0] - #4730
|
||||||
|
- ✅ TC006: 折扣等于下限 - 通过 [P0] - #4731
|
||||||
|
- ✅ TC007: 折扣高于下限 - 通过 [P1] - #4732
|
||||||
|
- 🔄 TC008: 多商品触发限制 - 罗列提示 [P0] - #4733
|
||||||
|
- ⏳ TC009: 非一级账号不受限 [P1] - #4734
|
||||||
|
- ⏳ TC010: 审核修改折扣低于下限 - 拦截 [P0] - #4735
|
||||||
|
- ⏳ TC011: 审核修改折扣等于下限 - 通过 [P0] - #4736
|
||||||
|
- ⏳ TC012: 限价标签显示 [P1] - #4737
|
||||||
|
- ⏳ TC013: 提示文字验证 [P1] - #4738
|
||||||
|
|
||||||
|
### 查询任务
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 获取酷采2.0项目任务列表
|
||||||
|
mcp__ai-proj__list_tasks({ projectId: 164 })
|
||||||
|
|
||||||
|
// 获取特定任务详情
|
||||||
|
mcp__ai-proj__get_detailed_task_info({ taskId: 4725 })
|
||||||
|
|
||||||
|
// 获取任务文档
|
||||||
|
mcp__ai-proj__get_task_document({ taskId: 4725 })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chrome DevTools 浏览器自动化
|
||||||
|
|
||||||
|
### 启动调试模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||||
|
--remote-debugging-port=9222 \
|
||||||
|
--user-data-dir=/tmp/chrome-debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证连接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:9222/json/version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证码处理规则
|
||||||
|
|
||||||
|
**重要**: 当访问需要验证码的系统时:
|
||||||
|
|
||||||
|
1. **不要**使用脚本截图方式获取验证码(验证码会快速过期)
|
||||||
|
2. **直接提醒用户**:
|
||||||
|
- 请在浏览器中输入验证码
|
||||||
|
- 或请帮我点击登录按钮
|
||||||
|
3. 用户操作完成后再继续自动化流程
|
||||||
|
|
||||||
|
示例提示:
|
||||||
|
```
|
||||||
|
浏览器已打开登录页面,验证码需要手动输入。请在 Chrome 浏览器中:
|
||||||
|
1. 输入验证码
|
||||||
|
2. 点击登录按钮
|
||||||
|
完成后告诉我,我将继续后续操作。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用 MCP 操作
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 列出所有页面
|
||||||
|
mcp__chrome-devtools__list_pages()
|
||||||
|
|
||||||
|
// 选择页面
|
||||||
|
mcp__chrome-devtools__select_page({ pageId: 1 })
|
||||||
|
|
||||||
|
// 导航到URL
|
||||||
|
mcp__chrome-devtools__navigate_page({
|
||||||
|
type: "url",
|
||||||
|
url: "http://47.105.185.154:9300/login"
|
||||||
|
})
|
||||||
|
|
||||||
|
// 截图
|
||||||
|
mcp__chrome-devtools__take_screenshot({
|
||||||
|
format: "png",
|
||||||
|
filePath: "/tmp/screenshot.png"
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取页面快照
|
||||||
|
mcp__chrome-devtools__take_snapshot()
|
||||||
|
|
||||||
|
// 点击元素
|
||||||
|
mcp__chrome-devtools__click({ uid: "element_uid" })
|
||||||
|
|
||||||
|
// 填写表单
|
||||||
|
mcp__chrome-devtools__fill({
|
||||||
|
uid: "input_uid",
|
||||||
|
value: "text"
|
||||||
|
})
|
||||||
|
|
||||||
|
// 执行JavaScript
|
||||||
|
mcp__chrome-devtools__evaluate_script({
|
||||||
|
function: "() => { return document.title; }"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见测试场景
|
||||||
|
|
||||||
|
### 1. 客户最低折扣测试
|
||||||
|
|
||||||
|
**测试步骤**:
|
||||||
|
1. 登录管理员账号 (19090009801)
|
||||||
|
2. 进入价格管理 → 最低折扣配置
|
||||||
|
3. 配置客户类型或特定客户的最低折扣
|
||||||
|
4. 使用客户账号 (17761202551) 登录
|
||||||
|
5. 创建订单,测试折扣限制规则
|
||||||
|
|
||||||
|
**验证点**:
|
||||||
|
- 折扣低于下限时系统拦截
|
||||||
|
- 折扣等于或高于下限时通过
|
||||||
|
- 多商品触发时正确罗列提示
|
||||||
|
- 非一级账号不受限制
|
||||||
|
|
||||||
|
### 2. 订单流程测试
|
||||||
|
|
||||||
|
**测试步骤**:
|
||||||
|
1. 客户账号登录
|
||||||
|
2. 选择商品加入购物车
|
||||||
|
3. 提交订单
|
||||||
|
4. 审核订单 (管理员)
|
||||||
|
5. 发货处理
|
||||||
|
6. 订单完成
|
||||||
|
|
||||||
|
### 3. 库存管理测试
|
||||||
|
|
||||||
|
**测试步骤**:
|
||||||
|
1. 查询库存
|
||||||
|
2. 创建库存调拨单
|
||||||
|
3. 审核调拨单
|
||||||
|
4. 确认入库
|
||||||
|
5. 验证库存变化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求对比测试流程
|
||||||
|
|
||||||
|
当酷采3.0实现新功能时,需要与酷采2.0进行对比:
|
||||||
|
|
||||||
|
### 测试流程
|
||||||
|
|
||||||
|
1. **功能分析**
|
||||||
|
- 在酷采2.0中找到对应功能
|
||||||
|
- 记录现有实现方式
|
||||||
|
- 识别差异点
|
||||||
|
|
||||||
|
2. **页面对比**
|
||||||
|
- 截图酷采2.0界面
|
||||||
|
- 对比UI/UX差异
|
||||||
|
- 记录交互流程
|
||||||
|
|
||||||
|
3. **数据对比**
|
||||||
|
- 对比数据模型
|
||||||
|
- 验证业务规则
|
||||||
|
- 确认边界条件
|
||||||
|
|
||||||
|
4. **性能对比**
|
||||||
|
- 记录响应时间
|
||||||
|
- 对比并发能力
|
||||||
|
- 评估用户体验
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题排查
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **登录失败**
|
||||||
|
- 检查账号密码是否正确
|
||||||
|
- 验证码是否过期
|
||||||
|
- 网络连接是否正常
|
||||||
|
|
||||||
|
2. **页面加载慢**
|
||||||
|
- 检查网络状况
|
||||||
|
- 清除浏览器缓存
|
||||||
|
- 查看服务器日志
|
||||||
|
|
||||||
|
3. **数据不同步**
|
||||||
|
- 刷新页面
|
||||||
|
- 检查数据库连接
|
||||||
|
- 查看后端日志
|
||||||
|
|
||||||
|
### 日志查看
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH到测试服务器
|
||||||
|
ssh root@47.105.185.154
|
||||||
|
|
||||||
|
# 查看应用日志
|
||||||
|
tail -f /path/to/coolbuy/logs/application.log
|
||||||
|
|
||||||
|
# 查看错误日志
|
||||||
|
tail -f /path/to/coolbuy/logs/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
- `coolbuy-paas` - 酷采3.0 SaaS租户端开发
|
||||||
|
- `coolbuy-platform` - 酷采3.0平台管理端
|
||||||
|
- `dev-test` - 软件测试技能
|
||||||
|
- `req` - 需求管理技能
|
||||||
|
- `siyuan` - 思源笔记(含酷采相关文档)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1.0.0 | 2026-01-21 | 初始版本,创建酷采2.0独立技能 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
⚠️ **重要提醒**:
|
||||||
|
|
||||||
|
1. 酷采2.0为遗留系统,主要用于参考和对比测试
|
||||||
|
2. 新功能开发应在酷采3.0 (coolbuy-paas) 中进行
|
||||||
|
3. 测试环境数据仅供测试使用,请勿在生产环境操作
|
||||||
|
4. 百丽、李宁等客户仍在使用此系统,测试时注意不要影响生产数据
|
||||||
|
5. 发现问题及时记录到 AI-Proj 系统中
|
||||||
8
plugins/coolbuy-paas-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/coolbuy-paas-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "coolbuy-paas-plugin",
|
||||||
|
"description": "酷采3.0 SaaS 租户端开发与测试。用于商品管理、订单管理等业务模块开发,以及酷采2.0系统对比测试。",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
803
plugins/coolbuy-paas-plugin/skills/SKILL.md
Normal file
803
plugins/coolbuy-paas-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
---
|
||||||
|
name: coolbuy-paas
|
||||||
|
description: 酷采3.0 SaaS 租户端开发与测试。用于商品管理、订单管理等业务模块开发,以及酷采2.0系统对比测试。当用户提到酷采、coolbuy-paas、商品管理、产品管理、酷采2.0测试相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Coolbuy PaaS Skill
|
||||||
|
|
||||||
|
酷采3.0 SaaS 多租户业务系统,采用 Go + React 技术栈。
|
||||||
|
|
||||||
|
## 项目信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 本地路径 | `/Users/donglinlai/coding/qiudl/coolbuy-paas` |
|
||||||
|
| 工作区路径 | `/Users/donglinlai/workspace/coolbuy-paas` |
|
||||||
|
| Git 仓库 | `git@gitea.pipexerp.com:pipexerp/coolbuy-paas.git` |
|
||||||
|
| 主分支 | main |
|
||||||
|
| 技术栈 | Go (Gin+GORM) + React 18 (Vite+TypeScript+Ant Design) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
coolbuy-paas/
|
||||||
|
├── erp-service/ # Go 后端 - ERP业务服务 (port 7091)
|
||||||
|
│ ├── api/ # API 定义 (go-zero)
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── product/ # 商品模块
|
||||||
|
│ │ ├── order/ # 订单模块
|
||||||
|
│ │ └── inventory/ # 库存模块
|
||||||
|
│ └── configs/
|
||||||
|
├── foundation-service/ # 基础服务 (port 7090)
|
||||||
|
├── auth-service/ # 认证服务 (port 7089)
|
||||||
|
├── ai-service/ # AI 服务 (port 7092) - AI Chat, Tool Calling
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── etc/ # 配置文件 (多环境)
|
||||||
|
│ └── services/
|
||||||
|
│ ├── chat_service.go # 对话服务
|
||||||
|
│ └── ai_ticket_reporter.go # AI 工单上报
|
||||||
|
├── gateway/ # API 网关
|
||||||
|
├── web/ # React 前端 - 租户端 (port 4000)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── services/ # API 服务
|
||||||
|
│ │ │ ├── aiChatApi.ts
|
||||||
|
│ │ │ └── chatSessionService.ts
|
||||||
|
│ │ └── components/AIChat/ # AI 聊天组件
|
||||||
|
│ └── .env.* # 环境配置
|
||||||
|
└── web-mall/ # React 前端 - 独立商城 (port 5174)
|
||||||
|
├── src/
|
||||||
|
│ ├── pages/ # 首页/分类/商品/购物车/结算/个人中心
|
||||||
|
│ ├── api/ # API 层(共享 erp-service)
|
||||||
|
│ ├── auth/ # 认证(共享 auth-service JWT)
|
||||||
|
│ ├── store/ # Zustand 状态管理
|
||||||
|
│ └── layouts/ # 商城布局(顶部导航,非侧边栏)
|
||||||
|
├── nginx.conf # 生产 Nginx 配置
|
||||||
|
└── Dockerfile # 多阶段构建
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 多环境配置
|
||||||
|
|
||||||
|
### 运行环境
|
||||||
|
|
||||||
|
| 环境 | 前端配置 | 后端配置 | 说明 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| development | `.env.development` | `ai-api.yaml` | 本地开发 |
|
||||||
|
| staging | `.env.staging` | `ai-api.staging.yaml` | 预发布环境 |
|
||||||
|
| production | `.env.production` | `ai-api.production.yaml` | 生产环境 |
|
||||||
|
|
||||||
|
### 核心环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 开发环境示例 |
|
||||||
|
|------|------|------------|
|
||||||
|
| `VITE_AUTH_SERVICE_URL` | 认证服务 | `http://127.0.0.1:7089` |
|
||||||
|
| `VITE_FOUNDATION_SERVICE_URL` | 基础服务 | `http://127.0.0.1:7090` |
|
||||||
|
| `VITE_BUSINESS_SERVICE_URL` | ERP服务 | `http://127.0.0.1:7091` |
|
||||||
|
| `VITE_AI_SERVICE_URL` | AI服务 | `http://127.0.0.1:7092` |
|
||||||
|
| `VITE_AI_PROJ_URL` | ai-proj服务 | `http://127.0.0.1:8080` |
|
||||||
|
|
||||||
|
### Vite 代理配置 (vite.config.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
proxy: {
|
||||||
|
'/api/v1/auth': { target: VITE_AUTH_SERVICE_URL },
|
||||||
|
'/api/v1/ai': { target: VITE_AI_SERVICE_URL }, // AI Chat
|
||||||
|
'/api/v1/chat': { target: VITE_AI_PROJ_URL }, // Chat Sessions
|
||||||
|
'/api/v1': { target: VITE_BUSINESS_SERVICE_URL }, // 默认路由
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI 服务集成
|
||||||
|
|
||||||
|
### AI Chat 功能
|
||||||
|
|
||||||
|
| 端点 | 服务 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/v1/ai/chat/stream` | ai-service (7092) | 流式对话 |
|
||||||
|
| `/api/v1/ai/tools` | ai-service (7092) | 获取可用工具 |
|
||||||
|
| `/api/v1/chat/sessions` | ai-proj (8080) | 会话管理 |
|
||||||
|
| `/api/v1/ai-tickets` | ai-proj (8080) | AI 工单 |
|
||||||
|
|
||||||
|
### AI Chat 代码路径(重要)
|
||||||
|
|
||||||
|
**前端实际调用链**:
|
||||||
|
```
|
||||||
|
useChat.ts
|
||||||
|
→ import * as aiChatService from '@/services/aiChatApi' ← 实际使用的文件!
|
||||||
|
→ aiChatApi.sendChatMessageStream()
|
||||||
|
→ aiChatApi.sendStreamingChatRequest() ← 用 fetch() 直接发 SSE 请求
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:`@/services/aiChatService.ts` 是遗留文件,**未被使用**。修改 AI Chat 前端逻辑时必须修改 `aiChatApi.ts`。
|
||||||
|
|
||||||
|
**流式请求绕过 axios**:`aiChatApi.ts` 使用原生 `fetch()` 发送 SSE 请求,不经过 `request.ts` 的 axios 拦截器。因此 `Authorization`、`X-Tenant-ID` 等 header 需要在 `fetch()` 中手动添加。
|
||||||
|
|
||||||
|
### AI Chat 认证链路
|
||||||
|
|
||||||
|
```
|
||||||
|
前端登录 → auth-service 返回 JWT (含 tenant_id, username, roles)
|
||||||
|
→ localStorage 存储 user_info + token
|
||||||
|
→ AI Chat 发送请求时:
|
||||||
|
1. getToken() 获取 JWT
|
||||||
|
2. getTenantIdFromContext() 获取 tenant_id
|
||||||
|
3. fetch() 携带 Authorization + X-Tenant-ID header
|
||||||
|
→ ai-service auth middleware 解析 JWT claims
|
||||||
|
→ Go context 注入 user_id, username, tenant_id, tenant_code, roles
|
||||||
|
→ buildUserIdentityContext(ctx) 提取并追加到 system prompt
|
||||||
|
→ AI 能回答"我的租户ID是什么"等问题
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键文件**:
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `web/src/services/aiChatApi.ts` | 前端 AI Chat API(实际使用) |
|
||||||
|
| `web/src/hooks/useChat.ts` | Chat 状态管理 Hook |
|
||||||
|
| `ai-service/api/internal/middleware/auth_middleware.go` | JWT 解析,注入 context |
|
||||||
|
| `ai-service/services/chat_service.go` | 对话服务,system prompt 注入 |
|
||||||
|
| `ai-service/services/context_builder.go` | 用户上下文构建 |
|
||||||
|
|
||||||
|
**JWT 密钥一致性**:auth-service 和 ai-service 的 `JWT.AccessSecret` 必须一致,否则 ai-service 无法验证 token(开发环境有 flexible parse 兜底)。
|
||||||
|
|
||||||
|
### AI 工单上报
|
||||||
|
|
||||||
|
ai-service 的错误会自动上报到 ai-proj 的 AI 工单系统:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ai-service/api/etc/ai-api.yaml
|
||||||
|
AITicketReporter:
|
||||||
|
Enabled: true
|
||||||
|
AiProjURL: "http://localhost:8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
错误类型自动分类:
|
||||||
|
- `api_error` - API 调用错误
|
||||||
|
- `missing_tool` - 工具调用错误
|
||||||
|
- `timeout` - 超时错误
|
||||||
|
- `permission_denied` - 权限错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 酷采2.0 测试环境 (Legacy)
|
||||||
|
|
||||||
|
用于参考和对比酷采3.0开发。
|
||||||
|
|
||||||
|
### 访问信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 测试地址 | http://47.105.185.154:9300/login |
|
||||||
|
| 管理员账号 | 19090009801 |
|
||||||
|
| 密码 | 123456 |
|
||||||
|
| 客户账号 | 17761202551 / 202551 |
|
||||||
|
| 技术栈 | Vue 2 + Element UI + Java Spring |
|
||||||
|
|
||||||
|
### 主要功能模块
|
||||||
|
|
||||||
|
- 推广方案、销售管理、草稿单管理
|
||||||
|
- Y码直客、库存管理、货盘管理
|
||||||
|
- 协同仓、价格管理、资金管理
|
||||||
|
- 基础功能、数据看板、公告通知
|
||||||
|
- 起售数量设置、**产品管理**
|
||||||
|
|
||||||
|
### 源码位置
|
||||||
|
|
||||||
|
```
|
||||||
|
~/workspace/coolbuy-legacy/
|
||||||
|
├── cool_lining/module-provider/ # Java 后端
|
||||||
|
│ └── src/main/java/com/jzg/module/
|
||||||
|
│ ├── action/prd/ # 商品控制器
|
||||||
|
│ ├── dao/model/prd/ # 商品实体
|
||||||
|
│ └── manager/prd/ # 商品业务逻辑
|
||||||
|
└── ln_admin/ # Vue 前端
|
||||||
|
└── src/views2/module/prd/ # 商品管理页面
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chrome DevTools 浏览器自动化
|
||||||
|
|
||||||
|
用于系统测试、截图、UI验证。
|
||||||
|
|
||||||
|
### 启动 Chrome 调试模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||||
|
--remote-debugging-port=9222 \
|
||||||
|
--user-data-dir=/tmp/chrome-debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证连接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:9222/json/version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证码处理规则
|
||||||
|
|
||||||
|
**重要**: 当访问需要验证码的系统时:
|
||||||
|
|
||||||
|
1. **不要**使用脚本截图方式获取验证码(验证码会快速过期)
|
||||||
|
2. **直接提醒用户**:
|
||||||
|
- 请在浏览器中输入验证码
|
||||||
|
- 或请帮我点击登录按钮
|
||||||
|
3. 用户操作完成后再继续自动化流程
|
||||||
|
|
||||||
|
示例提示:
|
||||||
|
```
|
||||||
|
浏览器已打开登录页面,验证码需要手动输入。请在 Chrome 浏览器中:
|
||||||
|
1. 输入验证码
|
||||||
|
2. 点击登录按钮
|
||||||
|
完成后告诉我,我将继续后续操作。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用 CDP 操作
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 截图
|
||||||
|
Page.captureScreenshot({ format: 'png' })
|
||||||
|
|
||||||
|
// 导航
|
||||||
|
Page.navigate({ url: 'http://...' })
|
||||||
|
|
||||||
|
// 执行 JS
|
||||||
|
Runtime.evaluate({ expression: '...' })
|
||||||
|
|
||||||
|
// 点击元素
|
||||||
|
Runtime.evaluate({
|
||||||
|
expression: `document.querySelector('button').click()`
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 酷采3.0 PRD 参考
|
||||||
|
|
||||||
|
PRD 文档存储在 ai-proj 任务系统:
|
||||||
|
|
||||||
|
| 模块 | 任务ID | 文档名称 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| 商品管理 | #4754 | 酷采3.0商品管理模块 |
|
||||||
|
| 商品基本信息 | #4770 | 商品基本信息管理模块PRD |
|
||||||
|
|
||||||
|
获取 PRD 文档:
|
||||||
|
```
|
||||||
|
mcp__ai-proj__get_task_document(taskId: 4770)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ERP 服务 (port 7091)
|
||||||
|
cd erp-service && go run api/main.go -f configs/config.local.yaml
|
||||||
|
|
||||||
|
# AI 服务 (port 7092)
|
||||||
|
cd ai-service && go run api/ai.go -f api/etc/ai-api.yaml
|
||||||
|
|
||||||
|
# 租户端前端 (port 4000)
|
||||||
|
cd web && npm run dev
|
||||||
|
|
||||||
|
# 独立商城前端 (port 5174)
|
||||||
|
cd web-mall && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖服务
|
||||||
|
|
||||||
|
| 服务 | 端口 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| PostgreSQL | 5432 | 本地数据库 |
|
||||||
|
| Redis | 6379 | 缓存 |
|
||||||
|
| ai-proj | 8080 | AI 项目管理(可选,用于 Chat Sessions) |
|
||||||
|
|
||||||
|
### 本地数据库
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | PostgreSQL |
|
||||||
|
| Database | paas_foundation |
|
||||||
|
| User | coolbuy-dev |
|
||||||
|
| Password | (空) |
|
||||||
|
|
||||||
|
### 远程数据库(生产参考)
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| Host | 192.144.137.14 |
|
||||||
|
| Database | coolbuy_paas |
|
||||||
|
| User | platform |
|
||||||
|
| Password | Zhiyuncai2025~ |
|
||||||
|
|
||||||
|
### 数据库管理工具 - TablePlus
|
||||||
|
|
||||||
|
**推荐工具**: TablePlus - macOS 原生数据库 GUI 客户端,支持 PostgreSQL/MySQL/Redis 等。
|
||||||
|
|
||||||
|
#### 安装 TablePlus
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 Homebrew 安装
|
||||||
|
brew install --cask tableplus
|
||||||
|
|
||||||
|
# 启动 TablePlus
|
||||||
|
open -a TablePlus
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 自动配置数据库连接
|
||||||
|
|
||||||
|
TablePlus 的连接配置存储在 `~/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist`。
|
||||||
|
|
||||||
|
**快速配置脚本**(已包含本地和远程连接):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 关闭 TablePlus(如果正在运行)
|
||||||
|
killall TablePlus 2>/dev/null || true
|
||||||
|
|
||||||
|
# 2. 备份现有配置
|
||||||
|
cp ~/Library/Application\ Support/com.tinyapp.TablePlus/Data/Connections.plist \
|
||||||
|
~/Library/Application\ Support/com.tinyapp.TablePlus/Data/Connections.plist.backup
|
||||||
|
|
||||||
|
# 3. 编辑配置文件添加连接(参考下方 plist 格式)
|
||||||
|
|
||||||
|
# 4. 重启 TablePlus
|
||||||
|
open -a TablePlus
|
||||||
|
```
|
||||||
|
|
||||||
|
**连接配置示例**(plist 格式):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- 本地开发环境连接 -->
|
||||||
|
<dict>
|
||||||
|
<key>ConnectionName</key>
|
||||||
|
<string>Coolbuy PaaS - Local (开发环境)</string>
|
||||||
|
<key>DatabaseHost</key>
|
||||||
|
<string>localhost</string>
|
||||||
|
<key>DatabasePort</key>
|
||||||
|
<string>5432</string>
|
||||||
|
<key>DatabaseName</key>
|
||||||
|
<string>paas_foundation</string>
|
||||||
|
<key>DatabaseUser</key>
|
||||||
|
<string>coolbuy-dev</string>
|
||||||
|
<key>DatabasePasswordMode</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
<key>Driver</key>
|
||||||
|
<string>PostgreSQL</string>
|
||||||
|
<key>Enviroment</key>
|
||||||
|
<string>local</string>
|
||||||
|
<key>statusColor</key>
|
||||||
|
<string>#3B82F6</string> <!-- 蓝色标识开发环境 -->
|
||||||
|
</dict>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 手动配置步骤
|
||||||
|
|
||||||
|
如果自动配置失败,可以手动在 TablePlus GUI 中添加:
|
||||||
|
|
||||||
|
1. 打开 TablePlus → 点击 "Create a new connection"
|
||||||
|
2. 选择 PostgreSQL
|
||||||
|
3. 填写连接信息:
|
||||||
|
|
||||||
|
**本地连接**:
|
||||||
|
- Name: `Coolbuy PaaS - Local (开发环境)`
|
||||||
|
- Host: `localhost`
|
||||||
|
- Port: `5432`
|
||||||
|
- Database: `paas_foundation`
|
||||||
|
- User: `coolbuy-dev`
|
||||||
|
- Password: (留空)
|
||||||
|
- Color: 蓝色
|
||||||
|
|
||||||
|
**生产连接**:
|
||||||
|
- Name: `Coolbuy PaaS - Production (生产环境)`
|
||||||
|
- Host: `192.144.137.14`
|
||||||
|
- Port: `5432`
|
||||||
|
- Database: `coolbuy_paas`
|
||||||
|
- User: `platform`
|
||||||
|
- Password: `Zhiyuncai2025~`
|
||||||
|
- Color: 红色(警示)
|
||||||
|
- **重要**:勾选 "Read-only mode" 防止误操作
|
||||||
|
|
||||||
|
#### 验证连接
|
||||||
|
|
||||||
|
连接成功后运行以下 SQL 验证:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 检查数据库版本
|
||||||
|
SELECT version();
|
||||||
|
|
||||||
|
-- 查看所有表
|
||||||
|
SELECT table_schema, table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
ORDER BY table_schema, table_name;
|
||||||
|
|
||||||
|
-- 查看库存预占记录
|
||||||
|
SELECT * FROM biz_inventory_reservations
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 10;
|
||||||
|
|
||||||
|
-- 查看活跃租户
|
||||||
|
SELECT id, name, code, status
|
||||||
|
FROM fnd_tenants
|
||||||
|
WHERE status = 1 AND deleted_at IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TablePlus 常用快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 功能 |
|
||||||
|
|--------|------|
|
||||||
|
| `Cmd + N` | 新建连接 |
|
||||||
|
| `Cmd + T` | 新建 Tab |
|
||||||
|
| `Cmd + K` | 连接列表 |
|
||||||
|
| `Cmd + R` | 刷新 |
|
||||||
|
| `Cmd + /` | 注释/取消注释 SQL |
|
||||||
|
| `Cmd + Enter` | 执行 SQL |
|
||||||
|
| `Cmd + Shift + E` | 导出数据 |
|
||||||
|
|
||||||
|
#### 安全提示
|
||||||
|
|
||||||
|
⚠️ **生产数据库操作注意事项**:
|
||||||
|
|
||||||
|
1. **必须**启用 "Read-only mode"(连接设置中)
|
||||||
|
2. **避免**直接执行 UPDATE/DELETE 操作
|
||||||
|
3. **建议**使用事务测试:`BEGIN; ... ROLLBACK;`
|
||||||
|
4. **慎用**批量操作
|
||||||
|
5. **定期**备份重要数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 独立商城 (web-mall)
|
||||||
|
|
||||||
|
独立 B2B 商城前端,与租户端 `web/` 并行运行,共享后端 API。
|
||||||
|
|
||||||
|
### 定位对比
|
||||||
|
|
||||||
|
| 维度 | 租户端 (web/) | 独立商城 (web-mall/) |
|
||||||
|
|------|--------------|---------------------|
|
||||||
|
| 定位 | 内部采购经理 | 终端采购用户 |
|
||||||
|
| 布局 | ERP 侧边栏 | 商城顶部导航 |
|
||||||
|
| 选品 | 表单弹窗选择 | 浏览分类/搜索+加购 |
|
||||||
|
| 下单 | ERP 表单模式 | 购物车→结算 |
|
||||||
|
|
||||||
|
### 页面结构
|
||||||
|
|
||||||
|
| 页面 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 首页 | `/` | 推荐商品、分类入口 |
|
||||||
|
| 商品列表 | `/products` | 搜索、筛选、排序 |
|
||||||
|
| 商品详情 | `/products/:id` | SKU 选择、加入购物车 |
|
||||||
|
| 购物车 | `/cart` | 购物车管理 |
|
||||||
|
| 结算 | `/checkout` | 提交订单 |
|
||||||
|
| 个人中心 | `/user` | 订单列表、订单详情 |
|
||||||
|
|
||||||
|
### Vite 代理配置 (web-mall/vite.config.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
proxy: {
|
||||||
|
'/api/v1/auth': { target: 'http://localhost:7089' }, // auth-service
|
||||||
|
'/api': { target: 'http://localhost:7091' }, // erp-service
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
| 环境 | 端口 | 镜像 |
|
||||||
|
|------|------|------|
|
||||||
|
| 开发 | 5174 (Vite) | 本地 `npm run dev` |
|
||||||
|
| 生产 | 8889:80 | `saltthing123/coolbuy-paas-web-mall` |
|
||||||
|
|
||||||
|
### 需求来源
|
||||||
|
|
||||||
|
REQ-20260214-0001(采购单双模式),PRD 任务 #5874。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 商品模块开发
|
||||||
|
|
||||||
|
### 前端页面结构
|
||||||
|
|
||||||
|
```
|
||||||
|
web/src/pages/Product/
|
||||||
|
├── ProductList/ # 商品列表页
|
||||||
|
│ ├── index.tsx
|
||||||
|
│ ├── columns.tsx # 表格列配置
|
||||||
|
│ └── searchFields.ts # 搜索表单配置
|
||||||
|
├── ProductForm/ # 商品表单页(新增/编辑)
|
||||||
|
│ ├── index.tsx
|
||||||
|
│ ├── BasicInfo.tsx # 基本信息
|
||||||
|
│ ├── ImageUpload.tsx # 图片上传
|
||||||
|
│ └── PriceInfo.tsx # 价格信息
|
||||||
|
└── ProductDetail/ # 商品详情弹窗
|
||||||
|
└── index.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端模块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
erp-service/internal/product/
|
||||||
|
├── model/
|
||||||
|
│ ├── spu.go # SPU 模型
|
||||||
|
│ └── sku.go # SKU 模型
|
||||||
|
├── biz/
|
||||||
|
│ ├── spu.go # SPU 业务逻辑
|
||||||
|
│ └── sku.go # SKU 业务逻辑
|
||||||
|
├── store/
|
||||||
|
│ ├── spu.go # SPU 数据访问
|
||||||
|
│ └── sku.go # SKU 数据访问
|
||||||
|
└── handler/
|
||||||
|
└── spu_handler.go # HTTP 处理器
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 端点
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | /api/v1/products | 商品列表 |
|
||||||
|
| GET | /api/v1/products/:id | 商品详情 |
|
||||||
|
| POST | /api/v1/products | 创建商品 |
|
||||||
|
| PUT | /api/v1/products/:id | 更新商品 |
|
||||||
|
| DELETE | /api/v1/products/:id | 删除商品 |
|
||||||
|
| PATCH | /api/v1/products/:id/status | 上架/下架 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 研发经验与开发策略
|
||||||
|
|
||||||
|
> 本章总结了酷采 3.0 开发过程中积累的研发方法论,指导 Claude Code 高效完成业务模块开发。
|
||||||
|
|
||||||
|
### 系统定位
|
||||||
|
|
||||||
|
酷采 3.0 是酷采 2.0 的**重构升级版本**,不是 1:1 复刻。核心策略:**用 2.0 的业务逻辑 + 3.0 的代码模式**。
|
||||||
|
|
||||||
|
### 两代系统架构对比
|
||||||
|
|
||||||
|
| 维度 | 酷采 2.0 (coolbuy-legacy) | 酷采 3.0 (coolbuy-paas) |
|
||||||
|
|------|--------------------------|------------------------|
|
||||||
|
| 后端 | Java Spring Boot 单体 | Go 微服务 (5 个独立服务) |
|
||||||
|
| 前端 | Vue 2 + Element UI | React 18 + Ant Design 5 + Vite |
|
||||||
|
| 数据库 | MySQL + MyBatis | PostgreSQL + GORM |
|
||||||
|
| 认证 | Shiro | JWT + Redis 黑名单 |
|
||||||
|
| 多租户 | company_id 字段 | 原生 SaaS tenant 隔离 |
|
||||||
|
| 权限 | 多表关联 RBAC | 权限引擎 + 数据权限 |
|
||||||
|
|
||||||
|
### 业务模块完成度
|
||||||
|
|
||||||
|
| 业务模块 | 2.0 页面 | 3.0 后端 | 3.0 前端 | 优先级 |
|
||||||
|
|----------|---------|---------|---------|--------|
|
||||||
|
| 系统管理 (用户/角色/权限/组织) | 9 页 | 95% | 95% | - 已完成 |
|
||||||
|
| 商品管理 (SPU/SKU/分类/品牌) | 18 页 | 90% | 85% | P1 补齐 |
|
||||||
|
| 订单管理 (销售/采购/退货/审批) | 22 页 | 85% | 70% | **P0** |
|
||||||
|
| 仓储管理 (入库/出库/库存/物流) | 6 页 | 70% | 50% | P0 |
|
||||||
|
| 客户管理 (等级/销售员/供应商) | 17 页 | 60% | 50% | P0 |
|
||||||
|
| 财务结算 (账户/流水/结算单) | 3 页 | 40% | 30% | P1 |
|
||||||
|
| 报备授权 (项目/渠道/审核) | 20+ 页 | 20% | 10% | P2 评估 |
|
||||||
|
| 分销网络 (3.0 新增) | 无 | 75% | 75% | P1 |
|
||||||
|
| 购物车 & 商城 (web-mall) | 无 | 80% | 80% | P1 |
|
||||||
|
|
||||||
|
### 三层工作流 (需求 → 代码)
|
||||||
|
|
||||||
|
```
|
||||||
|
第一层: 需求 → PRD (/req-prd skill)
|
||||||
|
输入: 2.0 对应页面 + 业务规则描述
|
||||||
|
输出: PRD 文档 + 原型 HTML (dev-plans/prototypes/)
|
||||||
|
工具: /req create → /req doc
|
||||||
|
|
||||||
|
第二层: PRD → 开发计划 (/req-dev skill)
|
||||||
|
输入: PRD + 当前 3.0 代码库分析
|
||||||
|
输出: 文件级开发任务拆分
|
||||||
|
工具: /req doc → ai-proj tasks
|
||||||
|
|
||||||
|
第三层: 任务 → 代码 (/dev-coding skill)
|
||||||
|
输入: 开发计划 + 参考模块代码
|
||||||
|
输出: 可运行的前后端代码
|
||||||
|
工具: Claude Code 直接编码
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模块复制模式 (核心开发方法)
|
||||||
|
|
||||||
|
每个新业务模块参照已有成熟模块的代码模式生成:
|
||||||
|
|
||||||
|
```
|
||||||
|
参考模块 (User) 目标模块 (Order)
|
||||||
|
──────────────────── ────────────────────
|
||||||
|
api/types.ts (追加接口) → OrderQueryParams, Order, OrderItem...
|
||||||
|
api/order.ts (新建) → getOrderList, getOrderById, createOrder...
|
||||||
|
pages/OrderList.tsx → 统计卡片 + 搜索表单 + 工具栏 + 表格
|
||||||
|
pages/OrderDetail.tsx → 头部信息 + 多 Tab (基本信息/明细/日志)
|
||||||
|
pages/OrderForm.tsx → Modal 表单 (新增/编辑)
|
||||||
|
router/tenantRoutes.tsx → 追加路由注册
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键约定**(必须遵循):
|
||||||
|
- API 字段使用 `snake_case` (如 `order_status`, `created_at`)
|
||||||
|
- 响应数据解包:`res.data?.data || res.data`(双层嵌套兼容)
|
||||||
|
- 分页参数:`{ current, pageSize }` → 后端映射为 `{ page, page_size }`
|
||||||
|
- 时间格式化:`dayjs(v).format('YYYY-MM-DD HH:mm:ss')`
|
||||||
|
- 枚举渲染:`<Tag color={colorMap[value]}>{labelMap[value]}</Tag>`
|
||||||
|
- 表格滚动:`scroll={{ x: 列宽总和 }}`
|
||||||
|
- 权限控制:`<Permission permission="module:resource:action">` 包裹按钮
|
||||||
|
|
||||||
|
### 单模块开发 SOP
|
||||||
|
|
||||||
|
| 步骤 | 产出 | 预估时间 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 1. 类型定义 | `types.ts` 追加接口/枚举 | 10 min |
|
||||||
|
| 2. API 层 | `api/xxx.ts` (CRUD + 业务接口) | 10 min |
|
||||||
|
| 3. 列表页 | `XxxList.tsx` (搜索+表格+工具栏+统计) | 20 min |
|
||||||
|
| 4. 详情页 | `XxxDetail.tsx` (多 Tab 信息展示) | 20 min |
|
||||||
|
| 5. 表单页 | `XxxForm.tsx` (新增/编辑 Modal) | 15 min |
|
||||||
|
| 6. 路由注册 | `tenantRoutes.tsx` 追加 lazy import | 5 min |
|
||||||
|
| 7. 联调修复 | 对接实际 API 后的字段/格式调整 | 30 min |
|
||||||
|
|
||||||
|
**单模块总计约 2 小时**,相比纯手写 2-3 天。
|
||||||
|
|
||||||
|
### 参考 2.0 代码的方法
|
||||||
|
|
||||||
|
开发新模块时,Claude 应按以下顺序获取业务知识:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 读 2.0 前端路由 → 了解页面结构和导航
|
||||||
|
~/workspace/coolbuy-paas/coolbuy-legacy/ln_admin/src/router/<module>_routers.js
|
||||||
|
|
||||||
|
# 2. 读 2.0 前端页面 → 了解字段、搜索条件、操作按钮
|
||||||
|
~/workspace/coolbuy-paas/coolbuy-legacy/ln_admin/src/views/module/<module>/
|
||||||
|
|
||||||
|
# 3. 读 2.0 后端 Manager → 了解业务规则和校验逻辑
|
||||||
|
~/workspace/coolbuy-paas/coolbuy-legacy/cool_belle/module-provider/src/main/java/com/jzg/module/manager/<module>/
|
||||||
|
|
||||||
|
# 4. 读 2.0 数据模型 → 了解表结构和字段
|
||||||
|
~/workspace/coolbuy-paas/coolbuy-legacy/cool_belle/module-provider/src/main/java/com/jzg/module/dao/model/<module>/
|
||||||
|
|
||||||
|
# 5. 读 3.0 已有参考模块 → 了解代码模式
|
||||||
|
~/coding/qiudl/coolbuy-paas/web/src/modules/foundation/pages/User/
|
||||||
|
~/coding/qiudl/coolbuy-paas/erp-service/internal/<module>/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 联调经验总结
|
||||||
|
|
||||||
|
以下是已验证的联调常见问题和修复模式:
|
||||||
|
|
||||||
|
| 问题 | 原因 | 修复模式 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 表格空数据 | API 响应格式 `{list,total}` vs `data[]` | `const d = res.data?.data \|\| res.data; setList(d?.list \|\| d)` |
|
||||||
|
| 按钮不显示 | Permission 组件依赖 `hasPermission()` | 确保 `isSuperAdmin()` 包含 `admin` 角色 |
|
||||||
|
| 时间显示原始 ISO | 缺少 dayjs 格式化 | 统一用 `formatTime()` helper |
|
||||||
|
| 统计接口 404 | 后端未部署 | `catch {}` 静默处理,不弹错误 |
|
||||||
|
| "加载更多"覆盖数据 | setState 替换而非追加 | 加 `append` 参数:`prev => append ? [...prev, ...newList] : newList` |
|
||||||
|
| TreeSelect undefined | 组织树数据映射缺字段 | 确保 `convertTreeData` 映射 `title/value/key` |
|
||||||
|
| TS 类型不匹配 | API 返回结构与类型定义不一致 | 用 `: any` 断言,后续补齐类型 |
|
||||||
|
| antd deprecated | v5.26 废弃 API | `bordered={false}` → `variant="borderless"` |
|
||||||
|
|
||||||
|
### 分阶段推进计划
|
||||||
|
|
||||||
|
**Phase 1 — 核心交易闭环** (决定系统可用性)
|
||||||
|
- 订单管理:后端 handler 已有 507 行,前端 20 页面待联调
|
||||||
|
- 客户管理:基础 CRUD + 客户等级 + 销售员体系
|
||||||
|
- 仓储管理:入库/出库/库存查询
|
||||||
|
|
||||||
|
**Phase 2 — 商品完善 + 财务**
|
||||||
|
- 商品模块补齐:组合方案、现货加标、生效时间管理
|
||||||
|
- 财务结算:账户管理、流水查询、结算单
|
||||||
|
|
||||||
|
**Phase 3 — 运营支撑**
|
||||||
|
- 报备/授权模块(评估后选择性迁移,2.0 有 20+ 页面)
|
||||||
|
- 报表/数据分析
|
||||||
|
- 小程序/H5 商城增强
|
||||||
|
|
||||||
|
### 2.0 订单模块业务知识 (已梳理)
|
||||||
|
|
||||||
|
#### 订单状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
创建 → 待支付(01) → 已支付(02) → 已发货(03) → 已收货(04) → 已完成(10)
|
||||||
|
↘ 已取消(05) ↘ 已退款(08)
|
||||||
|
待审核(06) 已关闭(07)
|
||||||
|
待出库(11)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 支付方式
|
||||||
|
|
||||||
|
| 代码 | 方式 | 业务规则 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 01 | 预存款 | 扣减预付账户余额,记 BizFundLog |
|
||||||
|
| 02 | 授信 | 扣减信用额度,需审核 |
|
||||||
|
| 11 | 银行转账 | 自动生成付款单(MallPay)+收款单(MallReceive) |
|
||||||
|
| 13 | 货到付款 | 无需预付 |
|
||||||
|
|
||||||
|
#### 退款类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 校验规则 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 整单退(01) | 全额退款 | 同一用户/订单只能有一个待处理退款 |
|
||||||
|
| 按数量退(02) | 部分数量退 | 检查可退数量 |
|
||||||
|
| 按物品退(03) | 指定商品退 | 逐项检查 |
|
||||||
|
| 退运费(04) | 仅退运费 | 运费大于 0 |
|
||||||
|
|
||||||
|
#### 用户角色与操作权限
|
||||||
|
|
||||||
|
| userType | 身份 | 可执行操作 |
|
||||||
|
|----------|------|-----------|
|
||||||
|
| 01 | 平台管理员 | 直接取消、审核、改价、所有操作 |
|
||||||
|
| 02 | 供应商 | 申请退款、查看、发货 |
|
||||||
|
| 03 | 客户 | 申请退款、查看、收货确认 |
|
||||||
|
| 04 | 前端用户 | 下单、查看、基础操作 |
|
||||||
|
|
||||||
|
### 3.0 订单模块现状 (已梳理)
|
||||||
|
|
||||||
|
#### 后端 API (erp-service)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/orders # 列表 (支持 OrderNo/Status/Date/Amount 筛选)
|
||||||
|
GET /api/v1/orders/:id # 详情 (含 items)
|
||||||
|
POST /api/v1/orders # 创建 (含采购权限/区域/MOQ 校验)
|
||||||
|
PUT /api/v1/orders/:id # 更新 (仅草稿)
|
||||||
|
DELETE /api/v1/orders/:id # 删除 (仅草稿)
|
||||||
|
POST /api/v1/orders/:id/submit-approval # 提交审批
|
||||||
|
POST /api/v1/orders/:id/approve # 审批通过/拒绝
|
||||||
|
POST /api/v1/orders/:id/confirm-payment # 确认付款
|
||||||
|
POST /api/v1/orders/:id/ship # 发货
|
||||||
|
POST /api/v1/orders/:id/confirm-delivery # 确认收货
|
||||||
|
POST /api/v1/orders/:id/complete # 完成
|
||||||
|
POST /api/v1/orders/:id/cancel # 取消
|
||||||
|
POST /api/v1/orders/:id/refund # 退款
|
||||||
|
GET /api/v1/orders/:id/status-logs # 状态变更日志
|
||||||
|
GET /api/v1/orders/:id/approval-history # 审批历史
|
||||||
|
GET /api/v1/orders/statistics/* # 统计 (6 个子接口)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.0 订单状态 (14 态,比 2.0 更细)
|
||||||
|
|
||||||
|
```
|
||||||
|
Draft(10) → PendingApproval(20) → Approved(29) → PendingPayment(30) → Paid(39)
|
||||||
|
→ PendingShipment(40) → PartialShipped(45) → Shipped(49)
|
||||||
|
→ PartialDelivered(55) → Delivered(59) → Completed(90)
|
||||||
|
→ Rejected(92) / Cancelled(95) / Refunded(97)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 前端已有页面 (20 个)
|
||||||
|
|
||||||
|
```
|
||||||
|
/order/sales - SalesOrderList
|
||||||
|
/order/sales/add - SalesOrderForm
|
||||||
|
/order/business - BusinessOrderList
|
||||||
|
/order/purchase - PurchaseOrderList
|
||||||
|
/order/purchase/add - PurchaseOrderForm
|
||||||
|
/order/purchase/:id - PurchaseOrderDetail
|
||||||
|
/order/pending-approval - PendingApprovalList
|
||||||
|
/order/return - ReturnOrderList
|
||||||
|
/order/return/create - CreateReturnOrder
|
||||||
|
/order/return/:id - ReturnOrderDetail
|
||||||
|
/order/statistics - PurchaseOrderStatistics
|
||||||
|
/checkout - CheckoutPage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
- `coolbuy-platform` - 平台管理端开发
|
||||||
|
- `coolbuy-legacy` - 酷采 2.0 测试与参考
|
||||||
|
- `dev-coding` - 软件编码开发
|
||||||
|
- `dev-arch` - 软件架构设计
|
||||||
|
- `dev-test` - 软件测试
|
||||||
|
- `req-prd` - 产品需求文档编写
|
||||||
|
- `req-dev` - 需求开发计划编写
|
||||||
|
- `siyuan` - 思源笔记(含酷采相关文档)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1.4.0 | 2026-02-25 | 新增研发经验与开发策略章节:两代系统对比、模块完成度、三层工作流、模块复制模式、联调经验、订单模块业务梳理 |
|
||||||
|
| 1.3.0 | 2026-02-14 | 新增数据库管理工具章节:TablePlus 安装配置、自动配置脚本、连接验证、安全提示 |
|
||||||
|
| 1.2.0 | 2026-02-14 | 新增 web-mall 独立商城模块(架构、页面、代理、部署端口) |
|
||||||
|
| 1.1.0 | 2026-02-13 | 新增 AI Chat 代码路径、认证链路、关键文件说明;记录 aiChatApi vs aiChatService 陷阱 |
|
||||||
|
| 1.0.0 | 2026-01-10 | 初始版本,添加酷采2.0测试环境和浏览器自动化指南 |
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "coolbuy-platform-plugin",
|
||||||
|
"description": "Coolbuy SaaS 平台管理端开发与部署。用于平台端前后端开发、租户管理、部署发布、翻译检查等任务。",
|
||||||
|
"version": "1.0.9",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
338
plugins/coolbuy-platform-plugin/skills/SKILL.md
Normal file
338
plugins/coolbuy-platform-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
---
|
||||||
|
name: coolbuy-platform
|
||||||
|
description: Coolbuy SaaS 平台管理端开发与部署。用于平台端前后端开发、租户管理、部署发布、翻译检查等任务。当用户提到 coolbuy-platform、平台端、租户管理后台相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Coolbuy Platform Skill
|
||||||
|
|
||||||
|
Coolbuy SaaS 平台管理端,用于管理所有租户、功能授权、计费、运营分析等。
|
||||||
|
|
||||||
|
## 项目信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 本地路径 | `/Users/donglinlai/coding/qiudl/coolbuy-platform` |
|
||||||
|
| Git 仓库 | `git@gitea.pipexerp.com:pipexerp/coolbuy-platform.git` |
|
||||||
|
| 主分支 | main |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
coolbuy-platform/
|
||||||
|
├── service/ # Go 后端 (Gin + GORM)
|
||||||
|
│ ├── cmd/ # 入口 main.go
|
||||||
|
│ ├── internal/admin/ # 核心业务
|
||||||
|
│ │ ├── api/ # HTTP handlers
|
||||||
|
│ │ ├── biz/ # 业务逻辑
|
||||||
|
│ │ ├── store/ # 数据访问
|
||||||
|
│ │ ├── model/ # 领域模型
|
||||||
|
│ │ └── middleware/ # 中间件
|
||||||
|
│ └── configs/ # 配置文件
|
||||||
|
└── web/ # React 前端 (Vite + TypeScript)
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ # API 客户端
|
||||||
|
│ ├── components/ # 组件
|
||||||
|
│ ├── pages/ # 页面
|
||||||
|
│ ├── stores/ # Zustand 状态
|
||||||
|
│ └── locales/ # i18n 翻译
|
||||||
|
└── dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署环境
|
||||||
|
|
||||||
|
### 生产服务器
|
||||||
|
|
||||||
|
| 服务 | 地址 | 端口 | 容器名 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 前端 | http://platform.pipexerp.com | 4999 | coolbuy-platform-web |
|
||||||
|
| 后端 | http://39.105.150.219 | 7090 | coolbuy-platform-service |
|
||||||
|
| Auth | http://39.105.150.219 | 7089 | coolbuy-auth-service |
|
||||||
|
|
||||||
|
### 服务器信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| IP | 39.105.150.219 |
|
||||||
|
| 用户 | root |
|
||||||
|
| SSH 密钥 | ~/.ssh/coolbuy3.pem |
|
||||||
|
| 操作系统 | Ubuntu 24.04 |
|
||||||
|
|
||||||
|
### 数据库
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | PostgreSQL 16 |
|
||||||
|
| Host | 172.18.0.1 (Docker 网关) |
|
||||||
|
| Port | 5432 |
|
||||||
|
| Database | paas_platform |
|
||||||
|
| User | platform |
|
||||||
|
| Password | Coolbuy2025~ |
|
||||||
|
|
||||||
|
### Docker Registry
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| Registry | Docker Hub |
|
||||||
|
| 账号 | saltthing123 |
|
||||||
|
| 前端镜像 | saltthing123/coolbuy-platform-web |
|
||||||
|
| 后端镜像 | saltthing123/coolbuy-platform-service |
|
||||||
|
| Auth镜像 | saltthing123/coolbuy-auth-service |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速部署命令
|
||||||
|
|
||||||
|
### 部署前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/donglinlai/coding/qiudl/coolbuy-platform/web
|
||||||
|
|
||||||
|
# 1. 构建
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
# 2. 打包 Docker 镜像
|
||||||
|
~/.orbstack/bin/docker build --platform linux/amd64 \
|
||||||
|
-t saltthing123/coolbuy-platform-web:<version> \
|
||||||
|
-t saltthing123/coolbuy-platform-web:latest .
|
||||||
|
|
||||||
|
# 3. 推送镜像
|
||||||
|
~/.orbstack/bin/docker push saltthing123/coolbuy-platform-web:<version>
|
||||||
|
~/.orbstack/bin/docker push saltthing123/coolbuy-platform-web:latest
|
||||||
|
|
||||||
|
# 4. 部署到服务器
|
||||||
|
ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "
|
||||||
|
docker pull saltthing123/coolbuy-platform-web:<version> && \
|
||||||
|
docker stop coolbuy-platform-web && \
|
||||||
|
docker rm coolbuy-platform-web && \
|
||||||
|
docker run -d --name coolbuy-platform-web \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 4999:80 \
|
||||||
|
saltthing123/coolbuy-platform-web:<version>
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部署后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/donglinlai/coding/qiudl/coolbuy-platform/service
|
||||||
|
|
||||||
|
# 1. 构建二进制
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o coolbuy-platform-service cmd/main.go
|
||||||
|
|
||||||
|
# 2. 打包 Docker 镜像
|
||||||
|
~/.orbstack/bin/docker build --platform linux/amd64 \
|
||||||
|
-t saltthing123/coolbuy-platform-service:<version> \
|
||||||
|
-t saltthing123/coolbuy-platform-service:latest .
|
||||||
|
|
||||||
|
# 3. 推送镜像
|
||||||
|
~/.orbstack/bin/docker push saltthing123/coolbuy-platform-service:<version>
|
||||||
|
~/.orbstack/bin/docker push saltthing123/coolbuy-platform-service:latest
|
||||||
|
|
||||||
|
# 4. 部署到服务器
|
||||||
|
ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "
|
||||||
|
docker pull saltthing123/coolbuy-platform-service:<version> && \
|
||||||
|
docker stop coolbuy-platform-service && \
|
||||||
|
docker rm coolbuy-platform-service && \
|
||||||
|
docker run -d --name coolbuy-platform-service \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 7090:7090 \
|
||||||
|
-v /data/coolbuy-platform/logs:/app/logs \
|
||||||
|
-v /data/coolbuy-platform/storage:/app/storage \
|
||||||
|
-v /data/coolbuy-platform/configs:/app/configs:ro \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
saltthing123/coolbuy-platform-service:<version> \
|
||||||
|
--config configs/config.prod.yaml
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 启动后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/donglinlai/coding/qiudl/coolbuy-platform/service
|
||||||
|
go run cmd/main.go -config configs/config-dev.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/donglinlai/coding/qiudl/coolbuy-platform/web
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端测试
|
||||||
|
cd service && go test -v ./...
|
||||||
|
|
||||||
|
# 前端测试
|
||||||
|
cd web && npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 翻译管理
|
||||||
|
|
||||||
|
### 翻译文件位置
|
||||||
|
|
||||||
|
- 简体中文: `web/src/locales/zh-CN.ts`
|
||||||
|
- 繁体中文: `web/src/locales/zh-TW.ts`
|
||||||
|
|
||||||
|
### 检查翻译缺失
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查找组件中使用的翻译 key
|
||||||
|
cd /Users/donglinlai/coding/qiudl/coolbuy-platform/web
|
||||||
|
grep -r "t(['\"]" src/components/ src/pages/ | grep -oE "t\(['\"][^'\"]+['\"]" | sort | uniq
|
||||||
|
```
|
||||||
|
|
||||||
|
### 翻译 key 命名规范
|
||||||
|
|
||||||
|
| 模块 | 前缀 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 租户管理 | tenant.* | tenant.form.nameRequired |
|
||||||
|
| 用户管理 | user.* | user.createSuccess |
|
||||||
|
| 角色管理 | role.* | role.permissionAssigned |
|
||||||
|
| 菜单管理 | menu.* | menu.iconRequired |
|
||||||
|
| 通知中心 | notification.* | notification.markAllRead |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
### 认证 (Auth Service - 7089)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | /api/v1/auth/login | 登录 |
|
||||||
|
| POST | /api/v1/auth/logout | 登出 |
|
||||||
|
| POST | /api/v1/auth/refresh | 刷新 Token |
|
||||||
|
|
||||||
|
### 平台管理 (Platform Service - 7090)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | /api/v1/admin/tenants | 租户列表 |
|
||||||
|
| POST | /api/v1/admin/tenants | 创建租户 |
|
||||||
|
| GET | /api/v1/admin/users | 用户列表 |
|
||||||
|
| GET | /api/v1/admin/roles | 角色列表 |
|
||||||
|
| GET | /api/v1/admin/menus | 菜单列表 |
|
||||||
|
| GET | /api/v1/admin/dashboard/overview | 仪表盘概览 |
|
||||||
|
|
||||||
|
### 健康检查
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| /health | 基础健康检查 |
|
||||||
|
| /health/detailed | 详细状态 |
|
||||||
|
| /readiness | K8s 就绪探针 |
|
||||||
|
| /liveness | K8s 存活探针 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库迁移
|
||||||
|
|
||||||
|
### 自动迁移
|
||||||
|
|
||||||
|
配置 `migration.auto: true` 时,服务启动自动运行迁移。
|
||||||
|
|
||||||
|
### 手动迁移
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h 172.18.0.1 -U platform -d paas_platform \
|
||||||
|
-f service/configs/migrations/001_create_platform_admin_tables.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. 翻译 key 不显示
|
||||||
|
|
||||||
|
组件使用的 key 与 locale 定义不匹配。检查:
|
||||||
|
- 组件使用 `t('tenant.domainConfig.*')` 但 locale 定义为 `tenant.domain.*`
|
||||||
|
- 需要添加别名命名空间
|
||||||
|
|
||||||
|
### 2. SSE 连接错误
|
||||||
|
|
||||||
|
通过 Cloudflare Tunnel 访问时 SSE 长连接会中断,属于已知问题,会自动重连。
|
||||||
|
|
||||||
|
### 3. Docker 构建失败
|
||||||
|
|
||||||
|
确保使用 OrbStack 的 docker:`~/.orbstack/bin/docker`
|
||||||
|
|
||||||
|
### 4. 数据库连接失败
|
||||||
|
|
||||||
|
Docker 网关 IP 是 `172.18.0.1`,不是默认的 `172.17.0.1`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1.0.9 | 2026-01-05 | 修复租户管理全部翻译缺失 |
|
||||||
|
| 1.0.8 | 2026-01-04 | 修复租户列表翻译 |
|
||||||
|
| 1.0.7 | 2026-01-04 | 修复租户表单翻译 |
|
||||||
|
| 1.0.0 | 2026-01-03 | 初始版本 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSH 快捷命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 连接服务器
|
||||||
|
ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219
|
||||||
|
|
||||||
|
# 查看容器状态
|
||||||
|
ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "docker ps | grep coolbuy"
|
||||||
|
|
||||||
|
# 查看后端日志
|
||||||
|
ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "docker logs -f coolbuy-platform-service --tail 100"
|
||||||
|
|
||||||
|
# 查看前端日志
|
||||||
|
ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "docker logs -f coolbuy-platform-web --tail 100"
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
ssh -i ~/.ssh/coolbuy3.pem root@39.105.150.219 "curl -s http://localhost:7090/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 创建租户
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 通过 API 创建租户
|
||||||
|
curl -X POST http://39.105.150.219:7090/api/v1/admin/tenants \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{
|
||||||
|
"name": "租户名称",
|
||||||
|
"code": "tenant_code",
|
||||||
|
"contact": "联系人",
|
||||||
|
"phone": "13800138000",
|
||||||
|
"email": "contact@example.com",
|
||||||
|
"user_limit": 50,
|
||||||
|
"storage_limit": 10,
|
||||||
|
"schema_type": "shared",
|
||||||
|
"admin_username": "tenantadmin",
|
||||||
|
"admin_real_name": "管理员姓名",
|
||||||
|
"status": "normal"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
- `ops-tools` - DevOps 运维工具,包含 Jenkins/Gitea 管理
|
||||||
|
- `dev-coding` - 软件编码开发
|
||||||
|
- `coolbuy-paas` - 租户端系统(待创建)
|
||||||
8
plugins/data-excel-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/data-excel-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "data-excel-plugin",
|
||||||
|
"description": "Plugin for data-excel",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
443
plugins/data-excel-plugin/skills/SKILL.md
Normal file
443
plugins/data-excel-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
---
|
||||||
|
name: data-excel
|
||||||
|
description: Excel 数据处理与 BI 集成。通过自然语言操作 Excel 文件的读取、编辑、转换,并支持导入到 BI 系统(Metabase)进行可视化分析。当用户提到 Excel、表格处理、数据导入、BI 分析相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Excel 数据处理与 BI 集成 Skill
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
- **Excel 读取**: 读取 .xlsx/.xls 文件内容,支持多 Sheet
|
||||||
|
- **Excel 编辑**: 修改单元格、添加/删除行列、格式化
|
||||||
|
- **数据转换**: Excel ↔ CSV ↔ JSON ↔ SQL
|
||||||
|
- **BI 集成**: 导入数据到 Metabase 进行可视化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python 包(推荐使用 uv)
|
||||||
|
uv pip install pandas openpyxl xlrd xlsxwriter sqlalchemy pymysql
|
||||||
|
|
||||||
|
# 或使用 pip
|
||||||
|
pip install pandas openpyxl xlrd xlsxwriter sqlalchemy pymysql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自然语言操作示例
|
||||||
|
|
||||||
|
### 读取操作
|
||||||
|
|
||||||
|
| 用户说 | 执行操作 |
|
||||||
|
|--------|----------|
|
||||||
|
| "读取这个 Excel 文件" | 读取并显示内容摘要 |
|
||||||
|
| "看一下第二个 Sheet" | 切换到指定 Sheet |
|
||||||
|
| "显示前 20 行" | 限制显示行数 |
|
||||||
|
| "这个表有哪些列" | 列出列名和数据类型 |
|
||||||
|
|
||||||
|
### 编辑操作
|
||||||
|
|
||||||
|
| 用户说 | 执行操作 |
|
||||||
|
|--------|----------|
|
||||||
|
| "把 A 列的空值填充为 0" | 填充缺失值 |
|
||||||
|
| "删除重复行" | 去重 |
|
||||||
|
| "把日期列转成 YYYY-MM-DD 格式" | 格式化日期 |
|
||||||
|
| "添加一列计算总价=单价×数量" | 新增计算列 |
|
||||||
|
| "筛选销售额大于 1000 的记录" | 过滤数据 |
|
||||||
|
| "按部门汇总销售额" | 分组聚合 |
|
||||||
|
|
||||||
|
### 导出操作
|
||||||
|
|
||||||
|
| 用户说 | 执行操作 |
|
||||||
|
|--------|----------|
|
||||||
|
| "导出为 CSV" | 转换格式 |
|
||||||
|
| "生成 SQL 插入语句" | 生成 INSERT 语句 |
|
||||||
|
| "导入到数据库" | 写入 MySQL/PostgreSQL |
|
||||||
|
| "上传到 Metabase" | BI 系统集成 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python 代码模板
|
||||||
|
|
||||||
|
### 读取 Excel
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# 读取 Excel 文件
|
||||||
|
df = pd.read_excel('data.xlsx')
|
||||||
|
|
||||||
|
# 读取指定 Sheet
|
||||||
|
df = pd.read_excel('data.xlsx', sheet_name='Sheet2')
|
||||||
|
|
||||||
|
# 读取所有 Sheet
|
||||||
|
all_sheets = pd.read_excel('data.xlsx', sheet_name=None)
|
||||||
|
for name, sheet_df in all_sheets.items():
|
||||||
|
print(f"Sheet: {name}, 行数: {len(sheet_df)}")
|
||||||
|
|
||||||
|
# 显示基本信息
|
||||||
|
print(df.head(10)) # 前 10 行
|
||||||
|
print(df.columns) # 列名
|
||||||
|
print(df.dtypes) # 数据类型
|
||||||
|
print(df.describe()) # 统计摘要
|
||||||
|
```
|
||||||
|
|
||||||
|
### 编辑 Excel
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
df = pd.read_excel('data.xlsx')
|
||||||
|
|
||||||
|
# 填充空值
|
||||||
|
df['列名'].fillna(0, inplace=True)
|
||||||
|
|
||||||
|
# 删除重复行
|
||||||
|
df.drop_duplicates(inplace=True)
|
||||||
|
|
||||||
|
# 添加计算列
|
||||||
|
df['总价'] = df['单价'] * df['数量']
|
||||||
|
|
||||||
|
# 筛选数据
|
||||||
|
df_filtered = df[df['销售额'] > 1000]
|
||||||
|
|
||||||
|
# 分组汇总
|
||||||
|
df_summary = df.groupby('部门')['销售额'].sum().reset_index()
|
||||||
|
|
||||||
|
# 日期格式化
|
||||||
|
df['日期'] = pd.to_datetime(df['日期']).dt.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 重命名列
|
||||||
|
df.rename(columns={'旧列名': '新列名'}, inplace=True)
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
df.sort_values(by='销售额', ascending=False, inplace=True)
|
||||||
|
|
||||||
|
# 保存修改
|
||||||
|
df.to_excel('output.xlsx', index=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 格式化 Excel(带样式)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||||
|
|
||||||
|
# 创建 Excel 并设置样式
|
||||||
|
df = pd.read_excel('data.xlsx')
|
||||||
|
|
||||||
|
with pd.ExcelWriter('styled_output.xlsx', engine='openpyxl') as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name='数据')
|
||||||
|
|
||||||
|
workbook = writer.book
|
||||||
|
worksheet = writer.sheets['数据']
|
||||||
|
|
||||||
|
# 设置标题行样式
|
||||||
|
header_font = Font(bold=True, color='FFFFFF')
|
||||||
|
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
|
||||||
|
|
||||||
|
for col in range(1, len(df.columns) + 1):
|
||||||
|
cell = worksheet.cell(row=1, column=col)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = Alignment(horizontal='center')
|
||||||
|
|
||||||
|
# 自动调整列宽
|
||||||
|
for column in worksheet.columns:
|
||||||
|
max_length = max(len(str(cell.value or '')) for cell in column)
|
||||||
|
worksheet.column_dimensions[column[0].column_letter].width = max_length + 2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据转换
|
||||||
|
|
||||||
|
### Excel → CSV
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
df = pd.read_excel('data.xlsx')
|
||||||
|
df.to_csv('output.csv', index=False, encoding='utf-8-sig') # utf-8-sig 解决中文乱码
|
||||||
|
```
|
||||||
|
|
||||||
|
### Excel → JSON
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
df = pd.read_excel('data.xlsx')
|
||||||
|
|
||||||
|
# 转为 JSON 数组
|
||||||
|
json_str = df.to_json(orient='records', force_ascii=False, indent=2)
|
||||||
|
print(json_str)
|
||||||
|
|
||||||
|
# 保存为文件
|
||||||
|
with open('output.json', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(json_str)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Excel → SQL INSERT
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
df = pd.read_excel('data.xlsx')
|
||||||
|
table_name = 'my_table'
|
||||||
|
|
||||||
|
# 生成 INSERT 语句
|
||||||
|
def generate_insert_sql(df, table_name):
|
||||||
|
columns = ', '.join(df.columns)
|
||||||
|
values_list = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
values = ', '.join([f"'{v}'" if isinstance(v, str) else str(v) for v in row])
|
||||||
|
values_list.append(f"({values})")
|
||||||
|
|
||||||
|
sql = f"INSERT INTO {table_name} ({columns}) VALUES\n" + ',\n'.join(values_list) + ';'
|
||||||
|
return sql
|
||||||
|
|
||||||
|
sql = generate_insert_sql(df, table_name)
|
||||||
|
print(sql)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Excel → MySQL 直接导入
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
|
# 数据库连接
|
||||||
|
engine = create_engine('mysql+pymysql://user:password@host:3306/database')
|
||||||
|
|
||||||
|
# 读取 Excel
|
||||||
|
df = pd.read_excel('data.xlsx')
|
||||||
|
|
||||||
|
# 导入数据库(如果表存在则替换)
|
||||||
|
df.to_sql('table_name', engine, if_exists='replace', index=False)
|
||||||
|
|
||||||
|
# 或追加数据
|
||||||
|
df.to_sql('table_name', engine, if_exists='append', index=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BI 系统集成 (Metabase)
|
||||||
|
|
||||||
|
### Metabase 服务信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 服务器 | prod-metaBI (192.144.174.87) |
|
||||||
|
| SSH 用户 | ubuntu |
|
||||||
|
| SSH 密钥 | ~/.ssh/prod_meta.pem |
|
||||||
|
| 系统 | Ubuntu 24.04 LTS |
|
||||||
|
| 数据源 | MySQL |
|
||||||
|
| 所属公司 | 北京欢乐宿 |
|
||||||
|
|
||||||
|
### SSH 连接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 连接 Metabase 服务器
|
||||||
|
ssh prod-metaBI
|
||||||
|
|
||||||
|
# 查看 MySQL 状态
|
||||||
|
ssh prod-metaBI "docker ps | grep mysql"
|
||||||
|
|
||||||
|
# 查看 Metabase 容器状态
|
||||||
|
ssh prod-metaBI "docker ps | grep metabase"
|
||||||
|
```
|
||||||
|
|
||||||
|
### MySQL 连接配置 (Metabase 数据源)
|
||||||
|
|
||||||
|
| 配置项 | 值 |
|
||||||
|
|--------|-----|
|
||||||
|
| Host | `127.0.0.1` |
|
||||||
|
| Port | `3306` |
|
||||||
|
| Database | `finance_db` |
|
||||||
|
| Username | `root` |
|
||||||
|
| Password | `root123456` |
|
||||||
|
|
||||||
|
> **注意**: MySQL 8.0 需使用 `mysql_native_password` 认证,否则会报 RSA 公钥错误。
|
||||||
|
|
||||||
|
### 数据导入流程
|
||||||
|
|
||||||
|
```
|
||||||
|
Excel 文件 → Python 处理 → MySQL 数据库 → Metabase 可视化
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整导入脚本
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
import os
|
||||||
|
|
||||||
|
def excel_to_metabase(excel_path, table_name, db_config):
|
||||||
|
"""
|
||||||
|
将 Excel 数据导入到 Metabase 可查询的数据库
|
||||||
|
|
||||||
|
Args:
|
||||||
|
excel_path: Excel 文件路径
|
||||||
|
table_name: 目标表名
|
||||||
|
db_config: 数据库配置 dict
|
||||||
|
"""
|
||||||
|
# 读取 Excel
|
||||||
|
df = pd.read_excel(excel_path)
|
||||||
|
|
||||||
|
# 数据清洗
|
||||||
|
df.columns = df.columns.str.strip() # 去除列名空格
|
||||||
|
df = df.dropna(how='all') # 删除全空行
|
||||||
|
|
||||||
|
# 连接数据库
|
||||||
|
engine = create_engine(
|
||||||
|
f"mysql+pymysql://{db_config['user']}:{db_config['password']}"
|
||||||
|
f"@{db_config['host']}:{db_config['port']}/{db_config['database']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 导入数据
|
||||||
|
df.to_sql(table_name, engine, if_exists='replace', index=False)
|
||||||
|
|
||||||
|
print(f"✓ 已导入 {len(df)} 行数据到表 {table_name}")
|
||||||
|
print(f"→ 现在可以在 Metabase 中查询此表")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
# 使用示例
|
||||||
|
db_config = {
|
||||||
|
'host': 'localhost',
|
||||||
|
'port': 3306,
|
||||||
|
'user': 'metabase_user',
|
||||||
|
'password': 'your_password',
|
||||||
|
'database': 'analytics'
|
||||||
|
}
|
||||||
|
|
||||||
|
excel_to_metabase('sales_data.xlsx', 'sales_report', db_config)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metabase 常用查询模板
|
||||||
|
|
||||||
|
导入数据后,在 Metabase 中可使用以下查询:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 数据概览
|
||||||
|
SELECT * FROM imported_table LIMIT 100;
|
||||||
|
|
||||||
|
-- 按日期汇总
|
||||||
|
SELECT DATE(created_at) as date, COUNT(*) as count, SUM(amount) as total
|
||||||
|
FROM imported_table
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date;
|
||||||
|
|
||||||
|
-- 分类统计
|
||||||
|
SELECT category, COUNT(*) as count, AVG(value) as avg_value
|
||||||
|
FROM imported_table
|
||||||
|
GROUP BY category
|
||||||
|
ORDER BY count DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见场景
|
||||||
|
|
||||||
|
### 场景 1: 合并多个 Excel 文件
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
import glob
|
||||||
|
|
||||||
|
# 合并目录下所有 Excel 文件
|
||||||
|
files = glob.glob('data/*.xlsx')
|
||||||
|
df_list = [pd.read_excel(f) for f in files]
|
||||||
|
df_merged = pd.concat(df_list, ignore_index=True)
|
||||||
|
|
||||||
|
df_merged.to_excel('merged.xlsx', index=False)
|
||||||
|
print(f"已合并 {len(files)} 个文件,共 {len(df_merged)} 行")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2: 数据透视表
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
df = pd.read_excel('sales.xlsx')
|
||||||
|
|
||||||
|
# 创建透视表
|
||||||
|
pivot = pd.pivot_table(
|
||||||
|
df,
|
||||||
|
values='销售额',
|
||||||
|
index='产品类别',
|
||||||
|
columns='月份',
|
||||||
|
aggfunc='sum',
|
||||||
|
fill_value=0
|
||||||
|
)
|
||||||
|
|
||||||
|
pivot.to_excel('pivot_report.xlsx')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3: 数据校验
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
df = pd.read_excel('data.xlsx')
|
||||||
|
|
||||||
|
# 检查空值
|
||||||
|
null_counts = df.isnull().sum()
|
||||||
|
print("空值统计:\n", null_counts[null_counts > 0])
|
||||||
|
|
||||||
|
# 检查重复
|
||||||
|
duplicates = df[df.duplicated()]
|
||||||
|
print(f"重复行数: {len(duplicates)}")
|
||||||
|
|
||||||
|
# 数据类型检查
|
||||||
|
print("数据类型:\n", df.dtypes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 4: 生成报表
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
df = pd.read_excel('data.xlsx')
|
||||||
|
|
||||||
|
# 生成多 Sheet 报表
|
||||||
|
with pd.ExcelWriter('report.xlsx') as writer:
|
||||||
|
# 原始数据
|
||||||
|
df.to_excel(writer, sheet_name='原始数据', index=False)
|
||||||
|
|
||||||
|
# 汇总数据
|
||||||
|
summary = df.groupby('部门').agg({
|
||||||
|
'销售额': 'sum',
|
||||||
|
'订单数': 'count'
|
||||||
|
}).reset_index()
|
||||||
|
summary.to_excel(writer, sheet_name='部门汇总', index=False)
|
||||||
|
|
||||||
|
# 统计信息
|
||||||
|
stats = df.describe()
|
||||||
|
stats.to_excel(writer, sheet_name='统计信息')
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 用户上传 Excel 文件或提供路径
|
||||||
|
2. 用自然语言描述需求(如"按月份汇总销售额")
|
||||||
|
3. Claude 生成并执行 Python 代码
|
||||||
|
4. 返回处理结果或导出文件
|
||||||
|
5. 可选:导入到 BI 系统进行可视化
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- Excel 文件路径使用绝对路径更可靠
|
||||||
|
- 中文 CSV 导出使用 `encoding='utf-8-sig'` 避免乱码
|
||||||
|
- 大文件(>10万行)考虑分批处理
|
||||||
|
- 导入数据库前先备份现有数据
|
||||||
|
- 敏感数据注意脱敏处理
|
||||||
8
plugins/dev-arch-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/dev-arch-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "dev-arch-plugin",
|
||||||
|
"description": "Plugin for dev-arch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
390
plugins/dev-arch-plugin/skills/SKILL.md
Normal file
390
plugins/dev-arch-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
---
|
||||||
|
name: dev-arch
|
||||||
|
description: 软件架构设计技能。用于系统设计、技术选型、架构评审、设计文档编写。当用户提到架构设计、系统设计、技术方案、API 设计相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 软件架构设计 Skill (dev-arch)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本技能用于软件架构设计工作,包括:
|
||||||
|
- 系统架构设计与评审
|
||||||
|
- 技术选型与方案对比
|
||||||
|
- API 设计与文档
|
||||||
|
- 数据库设计
|
||||||
|
- 设计模式应用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构设计流程
|
||||||
|
|
||||||
|
### 1. 需求分析
|
||||||
|
|
||||||
|
```
|
||||||
|
输入:
|
||||||
|
- 业务需求文档
|
||||||
|
- 非功能性需求(性能、安全、可用性)
|
||||||
|
- 技术约束条件
|
||||||
|
|
||||||
|
输出:
|
||||||
|
- 需求清单
|
||||||
|
- 约束条件列表
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 概要设计
|
||||||
|
|
||||||
|
```
|
||||||
|
内容:
|
||||||
|
- 系统边界定义
|
||||||
|
- 模块划分
|
||||||
|
- 技术栈选择
|
||||||
|
- 部署架构
|
||||||
|
|
||||||
|
输出:
|
||||||
|
- 架构图
|
||||||
|
- 技术方案文档
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 详细设计
|
||||||
|
|
||||||
|
```
|
||||||
|
内容:
|
||||||
|
- API 接口设计
|
||||||
|
- 数据模型设计
|
||||||
|
- 核心流程设计
|
||||||
|
- 异常处理设计
|
||||||
|
|
||||||
|
输出:
|
||||||
|
- API 文档
|
||||||
|
- ER 图
|
||||||
|
- 时序图
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构设计模板
|
||||||
|
|
||||||
|
### 系统设计文档模板
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [系统名称] 技术设计文档
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
### 1.1 背景
|
||||||
|
[项目背景和目标]
|
||||||
|
|
||||||
|
### 1.2 范围
|
||||||
|
[系统边界和功能范围]
|
||||||
|
|
||||||
|
### 1.3 术语定义
|
||||||
|
| 术语 | 定义 |
|
||||||
|
|------|------|
|
||||||
|
| ... | ... |
|
||||||
|
|
||||||
|
## 2. 系统架构
|
||||||
|
### 2.1 整体架构图
|
||||||
|
[架构图]
|
||||||
|
|
||||||
|
### 2.2 模块说明
|
||||||
|
| 模块 | 职责 | 技术栈 |
|
||||||
|
|------|------|--------|
|
||||||
|
| ... | ... | ... |
|
||||||
|
|
||||||
|
### 2.3 部署架构
|
||||||
|
[部署图]
|
||||||
|
|
||||||
|
## 3. 技术选型
|
||||||
|
### 3.1 技术栈
|
||||||
|
| 类别 | 选择 | 理由 |
|
||||||
|
|------|------|------|
|
||||||
|
| 后端框架 | ... | ... |
|
||||||
|
| 数据库 | ... | ... |
|
||||||
|
| 缓存 | ... | ... |
|
||||||
|
| 消息队列 | ... | ... |
|
||||||
|
|
||||||
|
### 3.2 技术对比
|
||||||
|
[备选方案对比分析]
|
||||||
|
|
||||||
|
## 4. 数据设计
|
||||||
|
### 4.1 ER 图
|
||||||
|
[ER 图]
|
||||||
|
|
||||||
|
### 4.2 核心表设计
|
||||||
|
[表结构说明]
|
||||||
|
|
||||||
|
### 4.3 索引策略
|
||||||
|
[索引设计]
|
||||||
|
|
||||||
|
## 5. API 设计
|
||||||
|
### 5.1 API 规范
|
||||||
|
[RESTful 规范说明]
|
||||||
|
|
||||||
|
### 5.2 核心接口
|
||||||
|
[接口定义]
|
||||||
|
|
||||||
|
## 6. 非功能性设计
|
||||||
|
### 6.1 性能设计
|
||||||
|
- 响应时间目标
|
||||||
|
- 吞吐量目标
|
||||||
|
- 优化策略
|
||||||
|
|
||||||
|
### 6.2 安全设计
|
||||||
|
- 认证授权
|
||||||
|
- 数据安全
|
||||||
|
- 日志审计
|
||||||
|
|
||||||
|
### 6.3 可用性设计
|
||||||
|
- 容错机制
|
||||||
|
- 监控告警
|
||||||
|
- 备份恢复
|
||||||
|
|
||||||
|
## 7. 风险评估
|
||||||
|
| 风险 | 影响 | 概率 | 应对措施 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| ... | ... | ... | ... |
|
||||||
|
|
||||||
|
## 8. 附录
|
||||||
|
- 参考资料
|
||||||
|
- 变更历史
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用架构模式
|
||||||
|
|
||||||
|
### 分层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 表现层 (Presentation) │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ 业务层 (Business) │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ 数据层 (Data Access) │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ 基础设施 (Infrastructure) │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 微服务架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────┐ ┌─────┐ ┌─────┐
|
||||||
|
│ API │ │ API │ │ API │
|
||||||
|
│ GW │──│ SVC │──│ SVC │
|
||||||
|
└──┬──┘ └──┬──┘ └──┬──┘
|
||||||
|
│ │ │
|
||||||
|
└────────┴────────┘
|
||||||
|
│
|
||||||
|
┌────┴────┐
|
||||||
|
│ Message │
|
||||||
|
│ Queue │
|
||||||
|
└─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 六边形架构 (端口-适配器)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
┌──────│ Adapters │──────┐
|
||||||
|
│ │ (Inbound) │ │
|
||||||
|
│ └────────┬────────┘ │
|
||||||
|
│ │ │
|
||||||
|
┌───┴───┐ ┌──────┴──────┐ ┌────┴────┐
|
||||||
|
│ REST │ │ Core │ │ Repo │
|
||||||
|
│ API │────│ Business │───│ Impl │
|
||||||
|
└───────┘ │ Domain │ └─────────┘
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 设计规范
|
||||||
|
|
||||||
|
### RESTful 规范
|
||||||
|
|
||||||
|
| 方法 | 用途 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | 查询资源 | `GET /users/{id}` |
|
||||||
|
| POST | 创建资源 | `POST /users` |
|
||||||
|
| PUT | 全量更新 | `PUT /users/{id}` |
|
||||||
|
| PATCH | 部分更新 | `PATCH /users/{id}` |
|
||||||
|
| DELETE | 删除资源 | `DELETE /users/{id}` |
|
||||||
|
|
||||||
|
### 响应格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {},
|
||||||
|
"meta": {
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"total": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误码设计
|
||||||
|
|
||||||
|
| 范围 | 类型 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 10000-19999 | 系统错误 | 10001 内部错误 |
|
||||||
|
| 20000-29999 | 参数错误 | 20001 参数缺失 |
|
||||||
|
| 30000-39999 | 业务错误 | 30001 用户不存在 |
|
||||||
|
| 40000-49999 | 权限错误 | 40001 未授权 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库设计
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
|
||||||
|
| 类型 | 规范 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 表名 | 小写下划线,复数 | `user_accounts` |
|
||||||
|
| 字段 | 小写下划线 | `created_at` |
|
||||||
|
| 主键 | id | `id` |
|
||||||
|
| 外键 | 表名_id | `user_id` |
|
||||||
|
| 索引 | idx_表名_字段 | `idx_users_email` |
|
||||||
|
|
||||||
|
### 通用字段
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 每个表必须包含的字段
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME DEFAULT NULL -- 软删除
|
||||||
|
```
|
||||||
|
|
||||||
|
### 索引策略
|
||||||
|
|
||||||
|
- 主键索引:每表必须
|
||||||
|
- 唯一索引:业务唯一字段
|
||||||
|
- 普通索引:高频查询字段
|
||||||
|
- 联合索引:遵循最左前缀原则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术选型参考
|
||||||
|
|
||||||
|
### 后端框架
|
||||||
|
|
||||||
|
| 语言 | 框架 | 适用场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Go | Gin, Echo | 高并发 API |
|
||||||
|
| Java | Spring Boot | 企业应用 |
|
||||||
|
| Python | FastAPI, Django | 快速开发 |
|
||||||
|
| Node.js | NestJS, Express | 全栈/实时 |
|
||||||
|
|
||||||
|
### 数据库选型
|
||||||
|
|
||||||
|
| 类型 | 选项 | 适用场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 关系型 | MySQL, PostgreSQL | 事务型业务 |
|
||||||
|
| 文档型 | MongoDB | 灵活结构 |
|
||||||
|
| 键值型 | Redis | 缓存/会话 |
|
||||||
|
| 时序 | InfluxDB | 监控/IoT |
|
||||||
|
| 搜索 | Elasticsearch | 全文检索 |
|
||||||
|
|
||||||
|
### 消息队列
|
||||||
|
|
||||||
|
| 选项 | 特点 | 适用场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Redis | 简单快速 | 轻量级队列 |
|
||||||
|
| RabbitMQ | 可靠传递 | 企业集成 |
|
||||||
|
| Kafka | 高吞吐 | 日志/流处理 |
|
||||||
|
| NATS | 轻量级 | 微服务通信 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构评审检查清单
|
||||||
|
|
||||||
|
### 功能性检查
|
||||||
|
- [ ] 需求覆盖完整
|
||||||
|
- [ ] 边界条件处理
|
||||||
|
- [ ] 异常情况处理
|
||||||
|
- [ ] 数据一致性保证
|
||||||
|
|
||||||
|
### 非功能性检查
|
||||||
|
- [ ] 性能目标明确
|
||||||
|
- [ ] 安全措施到位
|
||||||
|
- [ ] 可扩展性设计
|
||||||
|
- [ ] 可维护性考虑
|
||||||
|
|
||||||
|
### 运维检查
|
||||||
|
- [ ] 监控指标定义
|
||||||
|
- [ ] 日志规范
|
||||||
|
- [ ] 部署方案
|
||||||
|
- [ ] 回滚机制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 ai-proj 集成
|
||||||
|
|
||||||
|
### 创建架构设计任务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建架构设计任务
|
||||||
|
ai-proj task create --title "[系统名称] 架构设计"
|
||||||
|
|
||||||
|
# 创建子任务
|
||||||
|
ai-proj task create --title "需求分析" --parent-id <parentId>
|
||||||
|
ai-proj task create --title "概要设计" --parent-id <parentId>
|
||||||
|
ai-proj task create --title "详细设计" --parent-id <parentId>
|
||||||
|
ai-proj task create --title "架构评审" --parent-id <parentId>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关联设计文档
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 附加设计文档到任务
|
||||||
|
ai-proj task append-doc --id <taskId> --content "设计文档内容 (Markdown)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **KISS 原则** - 保持简单,避免过度设计
|
||||||
|
2. **YAGNI 原则** - 不要预测未来需求
|
||||||
|
3. **DRY 原则** - 避免重复代码
|
||||||
|
4. **SOLID 原则** - 面向对象设计原则
|
||||||
|
5. **关注点分离** - 模块职责单一
|
||||||
|
6. **高内聚低耦合** - 模块独立性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档工具
|
||||||
|
|
||||||
|
### 架构图工具
|
||||||
|
|
||||||
|
- **draw.io** - 在线免费绘图
|
||||||
|
- **PlantUML** - 代码生成图
|
||||||
|
- **Mermaid** - Markdown 内嵌图
|
||||||
|
- **Excalidraw** - 手绘风格
|
||||||
|
|
||||||
|
### Mermaid 示例
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
Client->>API Gateway: Request
|
||||||
|
API Gateway->>Auth Service: Validate Token
|
||||||
|
Auth Service-->>API Gateway: Valid
|
||||||
|
API Gateway->>Business Service: Forward Request
|
||||||
|
Business Service->>Database: Query
|
||||||
|
Database-->>Business Service: Result
|
||||||
|
Business Service-->>API Gateway: Response
|
||||||
|
API Gateway-->>Client: Response
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考资源
|
||||||
|
|
||||||
|
- [系统设计入门](https://github.com/donnemartin/system-design-primer)
|
||||||
|
- [微服务架构](https://microservices.io/)
|
||||||
|
- [RESTful API 设计指南](https://restfulapi.net/)
|
||||||
|
- [数据库设计范式](https://www.guru99.com/database-normalization.html)
|
||||||
8
plugins/dev-coding-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/dev-coding-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "dev-coding-plugin",
|
||||||
|
"description": "Plugin for dev-coding",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
810
plugins/dev-coding-plugin/skills/SKILL.md
Normal file
810
plugins/dev-coding-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,810 @@
|
|||||||
|
---
|
||||||
|
name: dev-coding
|
||||||
|
description: 软件编码开发技能。用于代码编写、功能实现、代码审查、重构优化。集成 ai-proj CLI 进行任务管理和进度跟踪。支持 Go、Vue、React、iOS、Android、小程序等全栈开发。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 软件编码开发 Skill (dev-coding)
|
||||||
|
|
||||||
|
## ⚠️ REQ 任务自动工作流
|
||||||
|
|
||||||
|
**当收到 REQ 任务(包含 REQ-YYYYMMDD-XXXX)需要开发时,必须严格按以下顺序执行:**
|
||||||
|
|
||||||
|
1. **读取 ticket** — 从 ai-proj 获取需求详情和关联文档
|
||||||
|
```
|
||||||
|
mcp__ai-proj-dev__get_detailed_task_info (通过 REQ 号查找)
|
||||||
|
mcp__ai-proj-dev__get_task_document (如果有 PRD 文档)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **进入 Plan Mode** — 调用 `EnterPlanMode` 工具
|
||||||
|
- 分析需求,探索代码库,设计实现方案
|
||||||
|
- 输出实现计划(涉及的文件、改动范围、测试策略)
|
||||||
|
- 等待用户审批后再开始编码
|
||||||
|
|
||||||
|
3. **执行计划** — 用户批准后按计划编码 + 写测试
|
||||||
|
|
||||||
|
**禁止跳过 plan mode 直接编码。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本技能用于软件编码开发工作,支持多种项目类型:
|
||||||
|
- Go 后端 (Gin + GORM)
|
||||||
|
- Vue 3 / React 前端
|
||||||
|
- iOS (Swift/SwiftUI)
|
||||||
|
- Android (Kotlin/Jetpack Compose)
|
||||||
|
- PDA 应用
|
||||||
|
- MCP 桥接服务
|
||||||
|
- 微服务架构
|
||||||
|
|
||||||
|
核心集成 **ai-proj CLI** 进行任务管理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ai-proj 任务管理
|
||||||
|
|
||||||
|
### 开发任务工作流
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 查看/创建任务 → 2. 启动任务 → 3. 编码实现 → 4. 完成任务 → 5. 记录文档
|
||||||
|
```
|
||||||
|
|
||||||
|
### 任务操作速查
|
||||||
|
|
||||||
|
| 操作 | CLI 命令 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 查看任务列表 | `ai-proj task list` | 查看项目所有任务 |
|
||||||
|
| 创建任务 | `ai-proj task create` | 创建新任务 |
|
||||||
|
| 创建子任务 | `ai-proj task create --parent-id` | 分解任务 |
|
||||||
|
| 启动任务 | `ai-proj task start --id` | 开始执行 |
|
||||||
|
| 完成任务 | `ai-proj task complete --id` | 标记完成 |
|
||||||
|
| 更新任务 | `ai-proj task update --id` | 更新状态/描述 |
|
||||||
|
| 查看详情 | `ai-proj task get --id` | 完整任务信息 |
|
||||||
|
| 记录文档 | `ai-proj task append-doc --id` | 附加文档 |
|
||||||
|
|
||||||
|
### 开始新任务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 查看任务列表
|
||||||
|
ai-proj task list --status todo,in_progress
|
||||||
|
|
||||||
|
# 2. 启动任务
|
||||||
|
ai-proj task start --id <taskId>
|
||||||
|
|
||||||
|
# 3. 完成后
|
||||||
|
ai-proj task complete --id <taskId>
|
||||||
|
|
||||||
|
# 4. 记录文档
|
||||||
|
ai-proj task append-doc --id <taskId> --content "实现说明"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目类型速查
|
||||||
|
|
||||||
|
### 当前项目生态
|
||||||
|
|
||||||
|
| 项目 | 类型 | 后端 | 前端 | 移动端 |
|
||||||
|
|------|------|------|------|--------|
|
||||||
|
| TWMS | 仓储物流 | Go+Gin+MySQL | Vue 3 | - |
|
||||||
|
| AI-Proj | 项目管理 | Go+Gin+PostgreSQL | React 18 | iOS+Android |
|
||||||
|
| DICIAI | 进销存SaaS | Go+Gin+MySQL | Vue 3 | Android PDA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go 后端开发
|
||||||
|
|
||||||
|
### 分层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── cmd/main.go # 入口点
|
||||||
|
├── internal/
|
||||||
|
│ ├── controller/handlers/ # HTTP 处理层
|
||||||
|
│ ├── biz/services/ # 业务逻辑层
|
||||||
|
│ ├── store/database/ # 数据访问层
|
||||||
|
│ └── middleware/ # 中间件
|
||||||
|
├── pkg/
|
||||||
|
│ ├── model/ # 数据模型
|
||||||
|
│ ├── errno/ # 错误定义
|
||||||
|
│ ├── api/ # API 类型
|
||||||
|
│ └── util/ # 工具函数
|
||||||
|
└── configs/migrations/ # 配置和迁移
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 包声明和导入组织
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// 标准库
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
// 第三方
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
// 项目内部
|
||||||
|
"project/internal/pkg/errno"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 错误处理 (Errno 模式)
|
||||||
|
if err != nil {
|
||||||
|
core.WriteResponse(c, errno.ErrBind, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口定义
|
||||||
|
type IStore interface {
|
||||||
|
Users() UserStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务注入
|
||||||
|
type userBiz struct {
|
||||||
|
ds store.IStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserBiz(ds store.IStore) *userBiz {
|
||||||
|
return &userBiz{ds: ds}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建
|
||||||
|
make build
|
||||||
|
go build -o ./_output/main ./cmd/main.go
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
./_output/main --config ./configs/config.yaml
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
make test
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
make lint
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
# Swagger 文档
|
||||||
|
make swagger
|
||||||
|
swag init -g cmd/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库模型
|
||||||
|
|
||||||
|
```go
|
||||||
|
type UserM struct {
|
||||||
|
Id int64 `gorm:"column:id;primary_key"`
|
||||||
|
Username string `gorm:"column:username;not null"`
|
||||||
|
CreateTime int64 `gorm:"column:create_time"`
|
||||||
|
UpdateTime int64 `gorm:"column:update_time"`
|
||||||
|
DeletedAt soft_delete.DeletedAt `gorm:"column:deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserM) TableName() string { return "users" }
|
||||||
|
|
||||||
|
func (m *UserM) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
m.CreateTime = time.Now().Unix()
|
||||||
|
m.UpdateTime = m.CreateTime
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前端 data-testid 规范
|
||||||
|
|
||||||
|
编写或修改前端组件时,**所有可交互元素必须加 `data-testid`**。
|
||||||
|
|
||||||
|
**命名格式:** `<模块>-<元素类型>[-<标识>]`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ✅ 正确 -->
|
||||||
|
<a-input data-testid="product-input-name" v-model:value="form.name" placeholder="商品名称" />
|
||||||
|
<a-button data-testid="product-btn-submit" type="primary">创建商品</a-button>
|
||||||
|
<a-select data-testid="product-select-brand" v-model:value="form.brandId" />
|
||||||
|
<a-table data-testid="product-table" :dataSource="list" />
|
||||||
|
|
||||||
|
<!-- ❌ 错误 — 交互元素无 data-testid -->
|
||||||
|
<a-input v-model:value="form.name" placeholder="商品名称" />
|
||||||
|
<a-button type="primary">创建商品</a-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**必须加:** 输入框、选择器、开关、按钮(提交/取消/删除)、表格、模态框确认按钮、导航菜单项
|
||||||
|
**不需要加:** 纯展示文本、图标、布局容器(Row/Col/Space)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vue 3 前端开发
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── api/ # API 服务 (按模块分组)
|
||||||
|
│ ├── wms/ # 仓储管理
|
||||||
|
│ ├── oms/ # 订单管理
|
||||||
|
│ └── system/ # 系统管理
|
||||||
|
├── views/ # 页面组件
|
||||||
|
├── components/ # 可复用组件
|
||||||
|
├── store/modules/ # Pinia 状态
|
||||||
|
├── router/ # 路由配置
|
||||||
|
├── utils/
|
||||||
|
│ ├── request.ts # Axios 拦截器
|
||||||
|
│ └── permission.ts # 权限检查
|
||||||
|
└── i18n/ # 国际化
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// API 服务层
|
||||||
|
// api/user/model/index.ts
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// api/user/index.ts
|
||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult, PageResult } from '@/api';
|
||||||
|
import type { User } from './model';
|
||||||
|
|
||||||
|
export async function getUsers(params: UserParams) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<User>>>(
|
||||||
|
'/v1/users',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
pnpm install # DICIAI 使用 pnpm
|
||||||
|
|
||||||
|
# 开发
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
npm run build:prod
|
||||||
|
npm run build:test
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
npm run lint:eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React 前端开发
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── pages/ # 页面组件
|
||||||
|
├── components/ # 可复用组件
|
||||||
|
├── services/ # API 服务
|
||||||
|
├── hooks/ # 自定义 Hooks
|
||||||
|
├── contexts/ # Context Providers
|
||||||
|
├── types/ # TypeScript 类型
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
└── config/ # 配置
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Context 集成
|
||||||
|
<QueryProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<TimerProvider>
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<Router>
|
||||||
|
<Routes />
|
||||||
|
</Router>
|
||||||
|
</ConfigProvider>
|
||||||
|
</TimerProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
|
||||||
|
// API 服务
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 120000,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = TokenManager.getToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
npm test
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## iOS 开发 (Swift/SwiftUI)
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
AI-Proj-iOS/
|
||||||
|
├── Core/ # 核心服务
|
||||||
|
│ ├── Network/ # 网络层
|
||||||
|
│ ├── Storage/ # 本地存储
|
||||||
|
│ └── Auth/ # 认证
|
||||||
|
├── Features/ # 功能模块
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ ├── Tasks/
|
||||||
|
│ └── Settings/
|
||||||
|
├── Models/ # 数据模型
|
||||||
|
└── UI/ # UI 组件
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// MVVM 架构
|
||||||
|
class TaskViewModel: ObservableObject {
|
||||||
|
@Published var tasks: [Task] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
|
||||||
|
private let taskService: TaskServiceProtocol
|
||||||
|
|
||||||
|
init(taskService: TaskServiceProtocol = TaskService()) {
|
||||||
|
self.taskService = taskService
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTasks() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
tasks = try await taskService.getTasks()
|
||||||
|
} catch {
|
||||||
|
// 错误处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwiftUI 视图
|
||||||
|
struct TaskListView: View {
|
||||||
|
@StateObject private var viewModel = TaskViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(viewModel.tasks) { task in
|
||||||
|
TaskRow(task: task)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.fetchTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Xcode 构建
|
||||||
|
xcodebuild -scheme AI-Proj-iOS -configuration Debug
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
xcodebuild test -scheme AI-Proj-iOS
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见问题排查
|
||||||
|
|
||||||
|
#### SwiftLint 沙盒错误
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
构建时出现错误:
|
||||||
|
```
|
||||||
|
Sandbox: swiftlint(xxxx) deny(1) file-read-data /path/to/.swiftlint.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
Xcode 15+ 默认启用 User Script Sandboxing,限制脚本访问文件系统。
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
方案 1 - 修改项目配置(推荐):
|
||||||
|
1. 打开 Xcode → 选择项目 → Build Settings
|
||||||
|
2. 搜索 "User Script Sandboxing"
|
||||||
|
3. 将 `ENABLE_USER_SCRIPT_SANDBOXING` 设置为 `NO`
|
||||||
|
|
||||||
|
方案 2 - 命令行构建时禁用:
|
||||||
|
```bash
|
||||||
|
xcodebuild -scheme AI-Proj-iOS -configuration Debug \
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING=NO
|
||||||
|
```
|
||||||
|
|
||||||
|
方案 3 - 直接修改 project.pbxproj:
|
||||||
|
```bash
|
||||||
|
sed -i '' 's/ENABLE_USER_SCRIPT_SANDBOXING = YES/ENABLE_USER_SCRIPT_SANDBOXING = NO/g' \
|
||||||
|
AI-Proj-iOS.xcodeproj/project.pbxproj
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Personal Development Team 功能限制
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
使用免费 Personal Team 签名时报错:
|
||||||
|
```
|
||||||
|
Cannot create iOS App Development provisioning profile...
|
||||||
|
Personal development teams do not support the Associated Domains,
|
||||||
|
Push Notifications and App Groups capabilities.
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
Personal Team(免费账户)不支持以下 Entitlements:
|
||||||
|
- Associated Domains (`com.apple.developer.associated-domains`)
|
||||||
|
- Push Notifications (`aps-environment`)
|
||||||
|
- App Groups (`com.apple.security.application-groups`)
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
1. 从 Entitlements 文件中移除不支持的功能:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- AI-Proj-iOS.entitlements -->
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- 仅保留 Personal Team 支持的功能 -->
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.yourcompany.app</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Personal Team 支持的功能:
|
||||||
|
- Keychain Access Groups ✓
|
||||||
|
- In-App Purchase ✓
|
||||||
|
- Game Center ✓
|
||||||
|
|
||||||
|
3. 需要付费 Apple Developer Program 的功能:
|
||||||
|
- Push Notifications ✗
|
||||||
|
- Associated Domains ✗
|
||||||
|
- App Groups ✗
|
||||||
|
- CloudKit ✗
|
||||||
|
- Sign in with Apple ✗
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Android 开发 (Kotlin)
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
android-app/app/src/main/
|
||||||
|
├── java/com/project/
|
||||||
|
│ ├── ui/ # UI 层
|
||||||
|
│ │ ├── screens/ # Compose 屏幕
|
||||||
|
│ │ └── components/ # 可复用组件
|
||||||
|
│ ├── data/ # 数据层
|
||||||
|
│ │ ├── api/ # 网络接口
|
||||||
|
│ │ ├── repository/ # 仓库模式
|
||||||
|
│ │ └── local/ # 本地存储
|
||||||
|
│ ├── domain/ # 业务逻辑
|
||||||
|
│ └── di/ # 依赖注入
|
||||||
|
└── res/ # 资源文件
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Hilt 依赖注入
|
||||||
|
@HiltViewModel
|
||||||
|
class TaskViewModel @Inject constructor(
|
||||||
|
private val taskRepository: TaskRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _tasks = MutableStateFlow<List<Task>>(emptyList())
|
||||||
|
val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()
|
||||||
|
|
||||||
|
fun fetchTasks() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
taskRepository.getTasks()
|
||||||
|
.collect { _tasks.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jetpack Compose
|
||||||
|
@Composable
|
||||||
|
fun TaskListScreen(
|
||||||
|
viewModel: TaskViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val tasks by viewModel.tasks.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn {
|
||||||
|
items(tasks) { task ->
|
||||||
|
TaskItem(task = task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建 Debug
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# 构建 Release
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PDA 应用开发
|
||||||
|
|
||||||
|
### 特点
|
||||||
|
|
||||||
|
- Android 原生开发
|
||||||
|
- 扫码枪集成
|
||||||
|
- 离线优先
|
||||||
|
- 简洁 UI
|
||||||
|
|
||||||
|
### 常见功能
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 扫码处理
|
||||||
|
class ScanReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val barcode = intent.getStringExtra("SCAN_BARCODE")
|
||||||
|
// 处理扫码结果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 离线存储
|
||||||
|
@Entity(tableName = "inventory")
|
||||||
|
data class Inventory(
|
||||||
|
@PrimaryKey val id: Long,
|
||||||
|
val barcode: String,
|
||||||
|
val quantity: Int,
|
||||||
|
@ColumnInfo(name = "sync_status")
|
||||||
|
val syncStatus: SyncStatus = SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP 桥接开发
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp-task-bridge/
|
||||||
|
├── index.ts # 入口
|
||||||
|
├── task-service.ts # 任务服务
|
||||||
|
├── document-service.ts # 文档服务
|
||||||
|
├── base-client.ts # HTTP 基类
|
||||||
|
├── types.ts # 类型定义
|
||||||
|
└── token-storage.ts # Token 管理
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 服务类模式
|
||||||
|
export class TaskService extends BaseClient {
|
||||||
|
async createTask(
|
||||||
|
title: string,
|
||||||
|
projectId: number = 1,
|
||||||
|
options: CreateTaskOptions = {}
|
||||||
|
): Promise<ApiResponse<Task>> {
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest<Task>(
|
||||||
|
'POST',
|
||||||
|
`/projects/${projectId}/tasks`,
|
||||||
|
{ title, project_id: projectId, ...options }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data,
|
||||||
|
message: `✅ 任务 "${title}" 创建成功`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `创建任务失败: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 通用开发规范
|
||||||
|
|
||||||
|
### API 响应格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分页参数
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"sort": "created_at",
|
||||||
|
"order": "desc"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 认证方式
|
||||||
|
|
||||||
|
- JWT Token
|
||||||
|
- Header: `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Go
|
||||||
|
if err != nil {
|
||||||
|
core.WriteResponse(c, errno.ErrXxx, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript
|
||||||
|
try {
|
||||||
|
const result = await api.call();
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swift
|
||||||
|
do {
|
||||||
|
let result = try await service.fetch()
|
||||||
|
} catch {
|
||||||
|
// 处理错误
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git 工作流
|
||||||
|
|
||||||
|
### 提交规范
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| feat | 新功能 |
|
||||||
|
| fix | Bug 修复 |
|
||||||
|
| docs | 文档 |
|
||||||
|
| refactor | 重构 |
|
||||||
|
| test | 测试 |
|
||||||
|
| chore | 杂项 |
|
||||||
|
|
||||||
|
### 双电脑同步 (au-dev / cn-dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 离开时
|
||||||
|
git add -A
|
||||||
|
git commit -m "WIP: sync from $(hostname)"
|
||||||
|
git push origin $(git branch --show-current)
|
||||||
|
|
||||||
|
# 到达时
|
||||||
|
git fetch origin
|
||||||
|
git pull origin $(git branch --show-current)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
### 标准配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
# 或 postgres:15
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用端口
|
||||||
|
|
||||||
|
| 服务 | 端口 |
|
||||||
|
|------|------|
|
||||||
|
| Backend | 8080 / 9099 |
|
||||||
|
| Frontend | 80 / 3000 |
|
||||||
|
| MySQL | 3306 |
|
||||||
|
| PostgreSQL | 5432 |
|
||||||
|
| Redis | 6379 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Push 前必须通过:变更包单元测试
|
||||||
|
|
||||||
|
**在 `git push` 或 `/pr create` 之前,必须跑所有变更文件对应包的单元测试。**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 找出变更的 Go 文件所在包,跑对应测试
|
||||||
|
PKGS=$(git diff --name-only origin/main...HEAD | grep '\.go$' | grep -v '_test\.go' | sed 's|/[^/]*$||' | sort -u | sed 's|^|./|' | tr '\n' ' ')
|
||||||
|
|
||||||
|
if [ -n "$PKGS" ]; then
|
||||||
|
echo "Running tests for changed packages: $PKGS"
|
||||||
|
go test -v -count=1 $PKGS
|
||||||
|
else
|
||||||
|
echo "No Go files changed, skipping tests"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
**规则:**
|
||||||
|
- 测试通过 → 继续 push + `/pr create`
|
||||||
|
- 测试失败 → 尝试自动修复,修复后重跑
|
||||||
|
- 修复成功 → 继续 push
|
||||||
|
- **修复失败 → 禁止 push,向用户报告失败原因,等待指示**
|
||||||
|
- 仅改了 `_test.go` → 同样需要跑(验证测试本身通过)
|
||||||
|
- 无 Go 文件变更(纯前端/文档) → 跳过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **任务驱动** - 使用 ai-proj 管理所有开发任务
|
||||||
|
2. **分层清晰** - Controller → Service → Repository
|
||||||
|
3. **接口先行** - 先定义接口再实现
|
||||||
|
4. **小步提交** - 频繁提交,每次做一件事
|
||||||
|
5. **测试覆盖** - 核心逻辑必须有测试
|
||||||
|
6. **文档同步** - 代码变更同步更新文档
|
||||||
8
plugins/dev-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/dev-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "dev-plugin",
|
||||||
|
"description": "Plugin for dev",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
314
plugins/dev-plugin/skills/SKILL.md
Normal file
314
plugins/dev-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
---
|
||||||
|
name: dev
|
||||||
|
description: 软件开发技能组入口。整合架构设计(dev-arch)、编码实现(dev-coding)、测试(dev-test)三个子技能,提供完整的软件开发工作流。支持全栈开发:Go、Vue、React、iOS、Android、小程序等。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 软件开发 Skill (dev)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
dev 是一个技能组入口,整合了软件开发的三个核心阶段:
|
||||||
|
|
||||||
|
| 子技能 | 用途 | 触发词 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| **dev-arch** | 架构设计、技术选型、系统设计 | 架构、设计、技术方案 |
|
||||||
|
| **dev-coding** | 编码实现、功能开发、代码审查 | 编码、开发、实现 |
|
||||||
|
| **dev-test** | 单元测试、集成测试、E2E测试 | 测试、test、覆盖率 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发工作流
|
||||||
|
|
||||||
|
```
|
||||||
|
需求分析 → 架构设计 → 编码实现 → 测试验证 → 部署上线
|
||||||
|
↓ ↓ ↓
|
||||||
|
dev-arch dev-coding dev-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 典型流程
|
||||||
|
|
||||||
|
1. **架构设计** (dev-arch)
|
||||||
|
- 需求分析
|
||||||
|
- 技术选型
|
||||||
|
- 系统设计文档
|
||||||
|
- 架构评审
|
||||||
|
|
||||||
|
2. **编码实现** (dev-coding)
|
||||||
|
- 任务分解
|
||||||
|
- 功能开发
|
||||||
|
- 代码审查
|
||||||
|
- 文档记录
|
||||||
|
|
||||||
|
3. **测试验证** (dev-test)
|
||||||
|
- 单元测试
|
||||||
|
- 集成测试
|
||||||
|
- E2E 测试
|
||||||
|
- 覆盖率分析
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 支持的项目类型
|
||||||
|
|
||||||
|
### 当前项目生态
|
||||||
|
|
||||||
|
| 项目 | 类型 | 技术栈 |
|
||||||
|
|------|------|--------|
|
||||||
|
| **TWMS** | 仓储物流 | Go + Vue 3 + MySQL |
|
||||||
|
| **AI-Proj** | 项目管理 | Go + React + PostgreSQL + iOS + Android |
|
||||||
|
| **DICIAI** | 进销存SaaS | Go + Vue 3 + MySQL + Android PDA |
|
||||||
|
|
||||||
|
### 技术栈矩阵
|
||||||
|
|
||||||
|
| 端 | 语言/框架 | 工具 |
|
||||||
|
|-----|----------|------|
|
||||||
|
| **后端** | Go (Gin + GORM) | MySQL/PostgreSQL, Redis, Docker |
|
||||||
|
| **Web前端** | Vue 3 / React 18 | TypeScript, Vite/CRA, Ant Design |
|
||||||
|
| **iOS** | Swift + SwiftUI | Xcode, XCTest |
|
||||||
|
| **Android** | Kotlin + Compose | Gradle, Hilt, Room |
|
||||||
|
| **PDA** | Android 原生 | 扫码枪集成, 离线存储 |
|
||||||
|
| **MCP** | TypeScript | Node.js, MCP SDK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ai-proj 任务管理集成
|
||||||
|
|
||||||
|
所有开发工作都通过 ai-proj CLI 进行任务管理:
|
||||||
|
|
||||||
|
### 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 查看待办任务
|
||||||
|
ai-proj task list --status in_progress,todo
|
||||||
|
|
||||||
|
# 2. 启动任务
|
||||||
|
ai-proj task start --id <taskId>
|
||||||
|
|
||||||
|
# 3. 完成任务
|
||||||
|
ai-proj task complete --id <taskId>
|
||||||
|
|
||||||
|
# 4. 记录文档
|
||||||
|
ai-proj task append-doc --id <taskId> --content "实现说明"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 任务分解
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建主任务
|
||||||
|
ai-proj task create --title "功能名称"
|
||||||
|
|
||||||
|
# 创建子任务
|
||||||
|
ai-proj task create --title "架构设计" --parent-id <parentId>
|
||||||
|
ai-proj task create --title "功能开发" --parent-id <parentId>
|
||||||
|
ai-proj task create --title "测试验证" --parent-id <parentId>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用命令速查
|
||||||
|
|
||||||
|
### Go 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建
|
||||||
|
make build
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
./_output/main --config ./configs/config.yaml
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
make test
|
||||||
|
make cover
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vue 前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
npm run build:prod
|
||||||
|
|
||||||
|
# 检查
|
||||||
|
npm run lint:eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
### React 前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
npm test
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建
|
||||||
|
xcodebuild -scheme ProjectName -configuration Debug
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
xcodebuild test -scheme ProjectName
|
||||||
|
```
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建
|
||||||
|
./gradlew assembleDebug
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
./gradlew test
|
||||||
|
./gradlew connectedAndroidTest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git 工作流
|
||||||
|
|
||||||
|
### 提交规范
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| feat | 新功能 |
|
||||||
|
| fix | Bug 修复 |
|
||||||
|
| docs | 文档 |
|
||||||
|
| refactor | 重构 |
|
||||||
|
| test | 测试 |
|
||||||
|
| chore | 杂项 |
|
||||||
|
|
||||||
|
### 分支策略
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 功能开发
|
||||||
|
git checkout -b feature/功能名称
|
||||||
|
|
||||||
|
# 提交
|
||||||
|
git commit -m "feat: 功能描述"
|
||||||
|
|
||||||
|
# 推送
|
||||||
|
git push origin feature/功能名称
|
||||||
|
|
||||||
|
# 合并
|
||||||
|
git checkout main
|
||||||
|
git merge feature/功能名称
|
||||||
|
```
|
||||||
|
|
||||||
|
### 双电脑同步 (au-dev / cn-dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 离开时
|
||||||
|
git add -A
|
||||||
|
git commit -m "WIP: sync from $(hostname)"
|
||||||
|
git push origin $(git branch --show-current)
|
||||||
|
|
||||||
|
# 到达时
|
||||||
|
git fetch origin
|
||||||
|
git pull origin $(git branch --show-current)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
### 标准配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
# 或 postgres:15
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用端口
|
||||||
|
|
||||||
|
| 服务 | 端口 |
|
||||||
|
|------|------|
|
||||||
|
| Backend | 8080 / 9099 |
|
||||||
|
| Frontend | 80 / 3000 |
|
||||||
|
| MySQL | 3306 |
|
||||||
|
| PostgreSQL | 5432 |
|
||||||
|
| Redis | 6379 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 子技能详情
|
||||||
|
|
||||||
|
### dev-arch (架构设计)
|
||||||
|
|
||||||
|
用于系统设计阶段:
|
||||||
|
- 需求分析
|
||||||
|
- 技术选型
|
||||||
|
- 架构设计文档
|
||||||
|
- API 设计
|
||||||
|
- 数据库设计
|
||||||
|
- 架构评审
|
||||||
|
|
||||||
|
### dev-coding (编码实现)
|
||||||
|
|
||||||
|
用于开发实现阶段:
|
||||||
|
- Go 后端开发
|
||||||
|
- Vue/React 前端开发
|
||||||
|
- iOS/Android 移动开发
|
||||||
|
- PDA 应用开发
|
||||||
|
- MCP 桥接开发
|
||||||
|
- 代码审查
|
||||||
|
|
||||||
|
### dev-test (测试)
|
||||||
|
|
||||||
|
用于测试验证阶段:
|
||||||
|
- 单元测试
|
||||||
|
- 集成测试
|
||||||
|
- E2E 测试
|
||||||
|
- UI 测试
|
||||||
|
- 覆盖率分析
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **任务驱动** - 使用 ai-proj 管理所有开发任务
|
||||||
|
2. **设计先行** - 复杂功能先设计后编码
|
||||||
|
3. **分层清晰** - Controller → Service → Repository
|
||||||
|
4. **小步提交** - 频繁提交,每次做一件事
|
||||||
|
5. **测试覆盖** - 核心逻辑必须有测试
|
||||||
|
6. **文档同步** - 代码变更同步更新文档
|
||||||
|
7. **代码审查** - 重要变更必须审查
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 何时使用哪个子技能
|
||||||
|
|
||||||
|
| 场景 | 推荐技能 |
|
||||||
|
|------|----------|
|
||||||
|
| 新功能设计 | dev-arch |
|
||||||
|
| 技术方案评审 | dev-arch |
|
||||||
|
| 功能开发实现 | dev-coding |
|
||||||
|
| Bug 修复 | dev-coding |
|
||||||
|
| 编写测试 | dev-test |
|
||||||
|
| 测试覆盖率提升 | dev-test |
|
||||||
|
| 代码审查 | dev-coding |
|
||||||
|
| 性能优化 | dev-arch + dev-coding |
|
||||||
8
plugins/dev-test-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/dev-test-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "dev-test-plugin",
|
||||||
|
"description": "软件测试技能。用于单元测试、集成测试、E2E测试、测试用例设计。支持 Go、Vue、React、iOS、Android 等多平台测试。",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
141
plugins/dev-test-plugin/skills/dev-test/SKILL.md
Normal file
141
plugins/dev-test-plugin/skills/dev-test/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: dev-test
|
||||||
|
description: 软件测试技能。用于单元测试、集成测试、E2E测试、测试用例设计。支持 Go、Vue、React、iOS、Android 等多平台测试。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 软件测试 Skill (dev-test)
|
||||||
|
|
||||||
|
## 子文件索引
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| `go-testing.md` | Go 后端测试 (testify + test DB + httptest)。**biz 层禁止 mock,必须用真实 PostgreSQL test DB** |
|
||||||
|
| `frontend-testing.md` | Vue (Vitest) + React (Jest) 前端测试 |
|
||||||
|
| `ios-testing.md` | iOS 测试 (XCTest + Swift Concurrency) |
|
||||||
|
| `android-testing.md` | Android 测试 (JUnit + Espresso + Compose) |
|
||||||
|
| `e2e-testing.md` | E2E Playwright + Coolbuy PaaS 集成测试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试金字塔
|
||||||
|
|
||||||
|
```
|
||||||
|
/\
|
||||||
|
/ \ E2E (少量)
|
||||||
|
/----\
|
||||||
|
/ \ 集成测试 (适量)
|
||||||
|
/--------\
|
||||||
|
/ \ 单元测试 (大量)
|
||||||
|
/------------\
|
||||||
|
```
|
||||||
|
|
||||||
|
| 类型 | 范围 | 速度 | 数量 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 单元测试 | 函数/方法 | 快 | 多 |
|
||||||
|
| 集成测试 | 模块交互 | 中 | 适量 |
|
||||||
|
| E2E 测试 | 完整流程 | 慢 | 少 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试命令速查
|
||||||
|
|
||||||
|
| 平台 | 命令 | 详见 |
|
||||||
|
|------|------|------|
|
||||||
|
| Go | `make test` / `go test ./...` | `go-testing.md` |
|
||||||
|
| Vue | `npm run test` | `frontend-testing.md` |
|
||||||
|
| React | `npm test` | `frontend-testing.md` |
|
||||||
|
| iOS | `xcodebuild test` | `ios-testing.md` |
|
||||||
|
| Android | `./gradlew test` | `android-testing.md` |
|
||||||
|
| E2E (通用) | `npm run test:e2e` | `e2e-testing.md` |
|
||||||
|
| E2E (Coolbuy PaaS) | `make e2e` | `e2e-testing.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chrome DevTools MCP (AI 浏览器调试)
|
||||||
|
|
||||||
|
> Google 官方 MCP 服务器,让 AI 助手直接控制和检查 Chrome 浏览器。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add chrome-devtools npx chrome-devtools-mcp@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
| 分类 | 工具 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **输入** | `click` / `fill` / `fill_form` / `hover` / `upload_file` | 页面交互 |
|
||||||
|
| **导航** | `navigate_page` / `new_page` / `list_pages` / `wait_for` | 页面导航 |
|
||||||
|
| **调试** | `evaluate_script` / `list_console_messages` / `take_screenshot` | 调试工具 |
|
||||||
|
| **网络** | `list_network_requests` / `get_network_request` | 网络分析 |
|
||||||
|
| **性能** | `performance_start_trace` / `performance_stop_trace` | 性能追踪 |
|
||||||
|
| **模拟** | `emulate_device` / `throttle_network` / `throttle_cpu` | 环境模拟 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试用例设计
|
||||||
|
|
||||||
|
### 等价类划分
|
||||||
|
|
||||||
|
| 输入 | 有效类 | 无效类 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 用户名 | 3-64字符 | <3, >64 |
|
||||||
|
| 年龄 | 0-150 | <0, >150 |
|
||||||
|
| 邮箱 | 有效格式 | 无效格式 |
|
||||||
|
|
||||||
|
### 边界值
|
||||||
|
|
||||||
|
```
|
||||||
|
范围 [1, 100]:
|
||||||
|
测试点: 0, 1, 2, 99, 100, 101
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试用例模板
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## TC-001: 用户登录成功
|
||||||
|
|
||||||
|
**前置条件**: 用户已注册
|
||||||
|
**步骤**:
|
||||||
|
1. 输入有效用户名
|
||||||
|
2. 输入有效密码
|
||||||
|
3. 点击登录
|
||||||
|
|
||||||
|
**预期**: 跳转到首页
|
||||||
|
**优先级**: P0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 覆盖率目标
|
||||||
|
|
||||||
|
| 类型 | 目标 |
|
||||||
|
|------|------|
|
||||||
|
| 行覆盖 | >80% |
|
||||||
|
| 分支覆盖 | >70% |
|
||||||
|
| 函数覆盖 | >90% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 ai-proj 集成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建测试任务
|
||||||
|
ai-proj task create --title "[模块] 单元测试"
|
||||||
|
|
||||||
|
# 记录测试结果
|
||||||
|
ai-proj task append-doc --id <taskId> --content "# 测试报告
|
||||||
|
- 覆盖率: 85%
|
||||||
|
- 通过: 42
|
||||||
|
- 失败: 0"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **测试金字塔** - 多单元测试,少 E2E
|
||||||
|
2. **测试隔离** - 每个测试独立
|
||||||
|
3. **命名清晰** - 描述预期行为
|
||||||
|
4. **快速反馈** - 测试要快
|
||||||
|
5. **持续集成** - 每次提交运行
|
||||||
|
6. **Biz 层禁止 Mock** - biz/service 层必须使用真实 PostgreSQL test DB + 真实 store,mock 等于没测
|
||||||
|
7. **Mock 仅限 Handler 层** - handler 层可以 mock biz 接口 + httptest
|
||||||
|
7. **李宁测试用例** - Excel 导出见 `coolbuy-legacy` 技能的 `test-cases-excel.md`
|
||||||
145
plugins/dev-test-plugin/skills/dev-test/android-testing.md
Normal file
145
plugins/dev-test-plugin/skills/dev-test/android-testing.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Android 测试 (JUnit + Espresso)
|
||||||
|
|
||||||
|
## 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 单元测试
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
# UI 测试
|
||||||
|
./gradlew connectedAndroidTest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 单元测试 (JUnit)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class TaskViewModelTest {
|
||||||
|
@get:Rule
|
||||||
|
val instantTaskRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val coroutineRule = MainCoroutineRule()
|
||||||
|
|
||||||
|
private lateinit var viewModel: TaskViewModel
|
||||||
|
private lateinit var repository: FakeTaskRepository
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
repository = FakeTaskRepository()
|
||||||
|
viewModel = TaskViewModel(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fetchTasks updates state`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
repository.addTasks(listOf(
|
||||||
|
Task(1, "Task 1"),
|
||||||
|
Task(2, "Task 2")
|
||||||
|
))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
viewModel.fetchTasks()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val tasks = viewModel.tasks.first()
|
||||||
|
assertEquals(2, tasks.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createTask adds task`() = runTest {
|
||||||
|
// Act
|
||||||
|
viewModel.createTask("New Task")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val tasks = viewModel.tasks.first()
|
||||||
|
assertTrue(tasks.any { it.title == "New Task" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI 测试 (Espresso)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@LargeTest
|
||||||
|
class TaskListActivityTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val activityRule = ActivityScenarioRule(TaskListActivity::class.java)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun displayTaskList() {
|
||||||
|
onView(withId(R.id.taskList))
|
||||||
|
.check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clickTask_opensDetail() {
|
||||||
|
onView(withId(R.id.taskList))
|
||||||
|
.perform(RecyclerViewActions.actionOnItemAtPosition<TaskViewHolder>(0, click()))
|
||||||
|
|
||||||
|
onView(withId(R.id.taskDetail))
|
||||||
|
.check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addTask_showsInList() {
|
||||||
|
// Click add button
|
||||||
|
onView(withId(R.id.addButton)).perform(click())
|
||||||
|
|
||||||
|
// Enter title
|
||||||
|
onView(withId(R.id.titleInput))
|
||||||
|
.perform(typeText("New Task"), closeSoftKeyboard())
|
||||||
|
|
||||||
|
// Save
|
||||||
|
onView(withId(R.id.saveButton)).perform(click())
|
||||||
|
|
||||||
|
// Verify in list
|
||||||
|
onView(withText("New Task"))
|
||||||
|
.check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compose UI 测试
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TaskListScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun taskList_displays() {
|
||||||
|
val tasks = listOf(
|
||||||
|
Task(1, "Task 1"),
|
||||||
|
Task(2, "Task 2")
|
||||||
|
)
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
TaskListScreen(tasks = tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Task 1").assertExists()
|
||||||
|
composeRule.onNodeWithText("Task 2").assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun taskClick_callsOnClick() {
|
||||||
|
var clickedId: Int? = null
|
||||||
|
val tasks = listOf(Task(1, "Task 1"))
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
TaskListScreen(
|
||||||
|
tasks = tasks,
|
||||||
|
onTaskClick = { clickedId = it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Task 1").performClick()
|
||||||
|
|
||||||
|
assertEquals(1, clickedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
169
plugins/dev-test-plugin/skills/dev-test/e2e-testing.md
Normal file
169
plugins/dev-test-plugin/skills/dev-test/e2e-testing.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# E2E 测试 (Playwright)
|
||||||
|
|
||||||
|
## 通用 Playwright 配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// playwright.config.ts
|
||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 30000,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||||
|
{ name: 'firefox', use: { browserName: 'firefox' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 通用 E2E 测试示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// login.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Login', () => {
|
||||||
|
test('successful login', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
|
||||||
|
await page.fill('[data-testid="username"]', 'testuser')
|
||||||
|
await page.fill('[data-testid="password"]', 'password')
|
||||||
|
await page.click('[data-testid="submit"]')
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/dashboard')
|
||||||
|
await expect(page.locator('.welcome')).toContainText('testuser')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalid credentials', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
|
||||||
|
await page.fill('[data-testid="username"]', 'wrong')
|
||||||
|
await page.fill('[data-testid="password"]', 'wrong')
|
||||||
|
await page.click('[data-testid="submit"]')
|
||||||
|
|
||||||
|
await expect(page.locator('.error')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Task Management', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.fill('[data-testid="username"]', 'testuser')
|
||||||
|
await page.fill('[data-testid="password"]', 'password')
|
||||||
|
await page.click('[data-testid="submit"]')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('create task', async ({ page }) => {
|
||||||
|
await page.click('[data-testid="new-task"]')
|
||||||
|
await page.fill('[data-testid="task-title"]', 'E2E Test Task')
|
||||||
|
await page.click('[data-testid="save"]')
|
||||||
|
|
||||||
|
await expect(page.locator('text=E2E Test Task')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coolbuy PaaS E2E 集成测试
|
||||||
|
|
||||||
|
> Playwright 全链路 E2E 测试,独立环境(DB + 端口),可与 dev 服务并行运行。
|
||||||
|
|
||||||
|
### 环境架构
|
||||||
|
|
||||||
|
| 服务 | Dev 端口 | E2E 端口 | DB |
|
||||||
|
|------|---------|---------|-----|
|
||||||
|
| Auth Service | 7089 | 7189 | coolbuy_paas_e2e |
|
||||||
|
| Foundation Service | 7090 | 7190 | coolbuy_paas_e2e |
|
||||||
|
| ERP Service | 7091 | 7191 | coolbuy_paas_e2e |
|
||||||
|
| Web Frontend | 4000 | 4010 | - |
|
||||||
|
|
||||||
|
**E2E DB 初始化**(首次/重置):
|
||||||
|
```bash
|
||||||
|
psql -U coolbuy-dev -d postgres -c "DROP DATABASE IF EXISTS coolbuy_paas_e2e;"
|
||||||
|
psql -U coolbuy-dev -d postgres -c "CREATE DATABASE coolbuy_paas_e2e OWNER \"coolbuy-dev\";"
|
||||||
|
pg_dump -U coolbuy-dev coolbuy_paas_local | psql -U coolbuy-dev coolbuy_paas_e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动 / 停止 E2E 服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make e2e-start # 启动全部 E2E 服务(auth/foundation/erp/web)
|
||||||
|
make e2e-stop # 停止全部 E2E 服务
|
||||||
|
make e2e-reset # 重置 DB 后启动
|
||||||
|
make e2e # 启动服务 + 运行全部测试
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本位置:`scripts/start-e2e-services.sh` / `scripts/stop-e2e-services.sh`
|
||||||
|
|
||||||
|
### 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
|
||||||
|
# 全部测试(无头模式)
|
||||||
|
npx playwright test
|
||||||
|
|
||||||
|
# 带 UI 调试
|
||||||
|
npx playwright test --headed
|
||||||
|
|
||||||
|
# 单个文件
|
||||||
|
npx playwright test tests/product-crud.spec.ts
|
||||||
|
|
||||||
|
# 查看 HTML 报告(注意:会启动 HTTP server,需 Ctrl+C 退出)
|
||||||
|
npx playwright show-report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth 自动登录
|
||||||
|
|
||||||
|
`tests/auth.setup.ts` 优先点击快速登录按钮(`VITE_ENABLE_QUICK_LOGIN=true`),降级为表单登录:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 快速登录(E2E 环境默认开启)
|
||||||
|
const quickLoginBtn = page.locator('button, a').filter({ hasText: /李宁|lining|ID:2/i }).first();
|
||||||
|
if (await quickLoginBtn.isVisible({ timeout: 3000 })) {
|
||||||
|
await quickLoginBtn.click();
|
||||||
|
} else {
|
||||||
|
// 降级:填写 lining_admin / admin123,验证码任意 4 位(SkipVerify=true)
|
||||||
|
}
|
||||||
|
await page.waitForURL(/\/tenant/, { timeout: 15000 });
|
||||||
|
await page.context().storageState({ path: authFile });
|
||||||
|
```
|
||||||
|
|
||||||
|
Session 保存至 `.auth/user.json`,后续测试自动复用,无需重复登录。
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `web/.env.e2e` | E2E 环境变量(端口 / 快速登录开关) |
|
||||||
|
| `web/playwright.config.ts` | baseURL=localhost:4010,reporter=[html, list] |
|
||||||
|
| `auth-service/api/etc/auth-api-e2e.yaml` | E2E auth 配置(SkipVerify=true) |
|
||||||
|
| `foundation-service/api/etc/foundation-api-e2e.yaml` | E2E foundation 配置 |
|
||||||
|
| `erp-service/configs/config.e2e.yaml` | E2E ERP 配置 |
|
||||||
|
|
||||||
|
### 测试结果解读
|
||||||
|
|
||||||
|
当前 **113 tests — 103 ✅ / 10 ❌**,已知失败项:
|
||||||
|
|
||||||
|
| 失败原因 | 涉及测试 |
|
||||||
|
|---------|---------|
|
||||||
|
| `/tenant/order/business` 路由 404(页面未实现) | 业务订单列表 × 3、订单模块导航 |
|
||||||
|
| 预警管理无搜索表单组件 | 预警管理搜索/筛选 |
|
||||||
|
| 库存管理页无 table/empty 状态 | 仓库管理-库存管理 |
|
||||||
|
| 待审批订单 networkidle 超时(>60s) | 待审批订单列表 |
|
||||||
|
| 数据权限/字段权限页渲染异常 | 系统管理 × 2 |
|
||||||
|
| 业务 CRM 页渲染异常 | 业务 CRM |
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
| 问题 | 解决 |
|
||||||
|
|------|------|
|
||||||
|
| `Executable doesn't exist` | `npx playwright install chromium` |
|
||||||
|
| 端口 4010 被占用 | `make e2e-stop` 后重试 |
|
||||||
|
| GORM migration 失败 | 检查 DB 是否有旧约束名,手动 DROP CONSTRAINT 后重启服务 |
|
||||||
|
| HTML 报告进程不退出 | Playwright 在 report 模式会启动 HTTP server,用 `Ctrl+C` 停止 |
|
||||||
174
plugins/dev-test-plugin/skills/dev-test/frontend-testing.md
Normal file
174
plugins/dev-test-plugin/skills/dev-test/frontend-testing.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# 前端测试 (Vue + React)
|
||||||
|
|
||||||
|
## Vue 前端测试
|
||||||
|
|
||||||
|
### 测试框架
|
||||||
|
|
||||||
|
- **Vitest**: 测试运行器
|
||||||
|
- **Vue Test Utils**: 组件测试
|
||||||
|
- **MSW**: API Mock
|
||||||
|
|
||||||
|
### 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test
|
||||||
|
npm run test:watch
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// UserList.test.ts
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import UserList from './UserList.vue'
|
||||||
|
|
||||||
|
describe('UserList', () => {
|
||||||
|
it('renders user list', () => {
|
||||||
|
const wrapper = mount(UserList, {
|
||||||
|
props: {
|
||||||
|
users: [
|
||||||
|
{ id: 1, name: 'Alice' },
|
||||||
|
{ id: 2, name: 'Bob' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.findAll('.user-item')).toHaveLength(2)
|
||||||
|
expect(wrapper.text()).toContain('Alice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits select event', async () => {
|
||||||
|
const wrapper = mount(UserList, {
|
||||||
|
props: { users: [{ id: 1, name: 'Alice' }] }
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('.user-item').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('select')).toBeTruthy()
|
||||||
|
expect(wrapper.emitted('select')[0]).toEqual([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state', () => {
|
||||||
|
const wrapper = mount(UserList, {
|
||||||
|
props: { users: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.empty-state').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Mock (MSW)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// mocks/handlers.ts
|
||||||
|
import { rest } from 'msw'
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
rest.get('/api/v1/users', (req, res, ctx) => {
|
||||||
|
return res(ctx.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
total: 2,
|
||||||
|
list: [{ id: 1, name: 'Alice' }]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
|
||||||
|
rest.post('/api/v1/users', async (req, res, ctx) => {
|
||||||
|
const body = await req.json()
|
||||||
|
return res(ctx.json({
|
||||||
|
code: 0,
|
||||||
|
data: { id: 3, ...body }
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React 前端测试
|
||||||
|
|
||||||
|
### 测试框架
|
||||||
|
|
||||||
|
- **Jest**: 测试运行器
|
||||||
|
- **React Testing Library**: 组件测试
|
||||||
|
- **Playwright**: E2E 测试
|
||||||
|
|
||||||
|
### 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run test:e2e
|
||||||
|
npm run test:e2e:headed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TaskCard.test.tsx
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import TaskCard from './TaskCard'
|
||||||
|
|
||||||
|
describe('TaskCard', () => {
|
||||||
|
const mockTask = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Task',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 'high'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders task title', () => {
|
||||||
|
render(<TaskCard task={mockTask} />)
|
||||||
|
expect(screen.getByText('Test Task')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays priority', () => {
|
||||||
|
render(<TaskCard task={mockTask} />)
|
||||||
|
expect(screen.getByText('high')).toHaveClass('priority-high')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClick', () => {
|
||||||
|
const handleClick = jest.fn()
|
||||||
|
render(<TaskCard task={mockTask} onClick={handleClick} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('article'))
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledWith(mockTask)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook 测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useTimer.test.ts
|
||||||
|
import { renderHook, act } from '@testing-library/react-hooks'
|
||||||
|
import { useTimer } from './useTimer'
|
||||||
|
|
||||||
|
describe('useTimer', () => {
|
||||||
|
beforeEach(() => jest.useFakeTimers())
|
||||||
|
afterEach(() => jest.useRealTimers())
|
||||||
|
|
||||||
|
it('starts timer', () => {
|
||||||
|
const { result } = renderHook(() => useTimer())
|
||||||
|
|
||||||
|
act(() => result.current.start())
|
||||||
|
|
||||||
|
expect(result.current.isRunning).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('increments time', () => {
|
||||||
|
const { result } = renderHook(() => useTimer())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.start()
|
||||||
|
jest.advanceTimersByTime(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.seconds).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
208
plugins/dev-test-plugin/skills/dev-test/go-testing.md
Normal file
208
plugins/dev-test-plugin/skills/dev-test/go-testing.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Go 后端测试
|
||||||
|
|
||||||
|
## 测试框架
|
||||||
|
|
||||||
|
- **testify**: 断言和套件
|
||||||
|
- **httptest**: HTTP 测试
|
||||||
|
- **gomock**: Mock 生成(仅用于 handler 层)
|
||||||
|
|
||||||
|
## ⚠️ Biz 层测试规则:禁止使用 Mock
|
||||||
|
|
||||||
|
**Biz/Service 层测试必须使用真实 PostgreSQL test DB,不允许使用 mock store。**
|
||||||
|
|
||||||
|
Mock store 只是在测试你的 mock 实现,无法验证真实的 SQL 行为、事务、FK 约束等。
|
||||||
|
|
||||||
|
| 层 | 测试方式 | 原因 |
|
||||||
|
|----|---------|------|
|
||||||
|
| model/store | **test DB** (PostgreSQL) | 验证真实 SQL/ORM 行为 |
|
||||||
|
| biz/service | **test DB** (PostgreSQL) + 真实 store | 验证业务逻辑 + 真实数据交互 |
|
||||||
|
| handler | **mock biz + httptest** | 只测 HTTP 路由和参数绑定 |
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ 正确 — biz 层使用真实 test DB + 真实 store
|
||||||
|
func setupBiz(t *testing.T) (*SomeBiz, *gorm.DB) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
s := store.NewSomeStore(db)
|
||||||
|
biz := NewSomeBiz(s)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM some_table WHERE tenant_id = ?", testTenantID)
|
||||||
|
})
|
||||||
|
return biz, db
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 错误 — biz 层使用 mock store(等于没测)
|
||||||
|
mockStore := store.NewMockIStore(ctrl)
|
||||||
|
mockStore.EXPECT().Get(gomock.Any(), id).Return(fakeData, nil)
|
||||||
|
biz := NewSomeBiz(mockStore)
|
||||||
|
```
|
||||||
|
|
||||||
|
### testdb_test.go 模板
|
||||||
|
|
||||||
|
```go
|
||||||
|
package biz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testTenantID int64 = 99
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T, models ...interface{}) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_DATABASE_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "host=localhost user=coolbuy-dev dbname=coolbuy_paas_test sslmode=disable"
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.AutoMigrate(models...))
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 所有测试
|
||||||
|
go test ./...
|
||||||
|
make test
|
||||||
|
|
||||||
|
# 带覆盖率
|
||||||
|
make cover
|
||||||
|
go test -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
|
||||||
|
# 特定包
|
||||||
|
go test -v ./internal/twms/biz/...
|
||||||
|
|
||||||
|
# 特定函数
|
||||||
|
go test -v -run TestFunctionName ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Biz 层单元测试模板(真实 DB)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package biz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"project/internal/user/model"
|
||||||
|
"project/internal/user/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupUserBiz(t *testing.T) (*UserBiz, *gorm.DB) {
|
||||||
|
db := newTestDB(t, &model.User{})
|
||||||
|
s := store.NewUserStore(db)
|
||||||
|
biz := NewUserBiz(s)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM users WHERE tenant_id = ?", testTenantID)
|
||||||
|
})
|
||||||
|
return biz, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestUser(t *testing.T, db *gorm.DB, username string) *model.User {
|
||||||
|
t.Helper()
|
||||||
|
user := &model.User{TenantID: testTenantID, Username: username, Email: username + "@test.com"}
|
||||||
|
require.NoError(t, db.Create(user).Error)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserBiz_Get(t *testing.T) {
|
||||||
|
biz, db := setupUserBiz(t)
|
||||||
|
user := createTestUser(t, db, "john")
|
||||||
|
|
||||||
|
result, err := biz.Get(context.Background(), user.ID)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "john", result.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserBiz_Get_NotFound(t *testing.T) {
|
||||||
|
biz, _ := setupUserBiz(t)
|
||||||
|
|
||||||
|
_, err := biz.Get(context.Background(), 99999)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 表驱动测试
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestValidateUsername(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", "john_doe", false},
|
||||||
|
{"too_short", "ab", true},
|
||||||
|
{"too_long", strings.Repeat("a", 65), true},
|
||||||
|
{"special_chars", "user@name", true},
|
||||||
|
{"empty", "", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateUsername(tt.input)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP Handler 测试
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestUserController_List(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
|
mockBiz := biz.NewMockIBiz(ctrl)
|
||||||
|
mockUserBiz := biz.NewMockUserBiz(ctrl)
|
||||||
|
|
||||||
|
mockBiz.EXPECT().Users().Return(mockUserBiz).AnyTimes()
|
||||||
|
mockUserBiz.EXPECT().List(gomock.Any(), gomock.Any()).Return(&v1.ListUsersResponse{
|
||||||
|
Total: 1,
|
||||||
|
Users: []*v1.User{{Id: 1, Username: "test"}},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
controller := NewUserController(mockBiz)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest("GET", "/v1/users?page=1&limit=10", nil)
|
||||||
|
|
||||||
|
controller.List(c)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mock 生成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成 Mock
|
||||||
|
mockgen -source=internal/twms/store/store.go \
|
||||||
|
-destination=internal/twms/store/mock_store.go \
|
||||||
|
-package=store
|
||||||
|
|
||||||
|
# go:generate 方式
|
||||||
|
//go:generate mockgen -source=store.go -destination=mock_store.go -package=store
|
||||||
|
```
|
||||||
157
plugins/dev-test-plugin/skills/dev-test/ios-testing.md
Normal file
157
plugins/dev-test-plugin/skills/dev-test/ios-testing.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# iOS 测试 (XCTest + Swift Concurrency)
|
||||||
|
|
||||||
|
## 测试框架
|
||||||
|
|
||||||
|
- **XCTest**: Apple 官方测试框架
|
||||||
|
- **Swift Testing**: Swift 6 新测试框架 (可选)
|
||||||
|
- **ViewInspector**: SwiftUI 视图测试 (第三方)
|
||||||
|
|
||||||
|
## 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 全部测试
|
||||||
|
xcodebuild test \
|
||||||
|
-scheme AI-Proj-iOS \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||||
|
-quiet
|
||||||
|
|
||||||
|
# 特定测试类
|
||||||
|
xcodebuild test \
|
||||||
|
-scheme AI-Proj-iOS \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||||
|
-only-testing:AI-Proj-iOSTests/DashboardViewModelTests
|
||||||
|
|
||||||
|
# 覆盖率
|
||||||
|
xcodebuild test \
|
||||||
|
-scheme AI-Proj-iOS \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||||
|
-enableCodeCoverage YES
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目测试结构 (AI-Proj-iOS)
|
||||||
|
|
||||||
|
```
|
||||||
|
AI-Proj-iOSTests/
|
||||||
|
├── Mocks/
|
||||||
|
│ ├── MockServices.swift # Mock 服务协议实现
|
||||||
|
│ └── MockNetworkService.swift
|
||||||
|
├── ViewModels/
|
||||||
|
│ ├── DashboardViewModelTests.swift
|
||||||
|
│ ├── TaskViewModelTests.swift
|
||||||
|
│ └── RequirementViewModelTests.swift
|
||||||
|
├── Services/
|
||||||
|
│ ├── TaskServiceTests.swift
|
||||||
|
│ └── DashboardAggregationServiceTests.swift
|
||||||
|
├── Models/
|
||||||
|
│ └── ModelDecodingTests.swift
|
||||||
|
└── Utilities/
|
||||||
|
└── DateFormatterTests.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键模式
|
||||||
|
|
||||||
|
### 1. Mock 服务 — Result 注入
|
||||||
|
|
||||||
|
```swift
|
||||||
|
class MockTaskService: TaskServiceProtocol {
|
||||||
|
var fetchTasksResult: Result<TaskListResponse, Error> = .success(.mock)
|
||||||
|
|
||||||
|
func fetchTasks(...) async throws -> TaskListResponse {
|
||||||
|
switch fetchTasksResult {
|
||||||
|
case .success(let response): return response
|
||||||
|
case .failure(let error): throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 Mock 服务统一用 `Result` 属性控制成功/失败返回。
|
||||||
|
|
||||||
|
### 2. ViewModel 测试 — @MainActor + async
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@MainActor
|
||||||
|
final class DashboardViewModelTests: XCTestCase {
|
||||||
|
var sut: DashboardViewModel!
|
||||||
|
var mockService: MockDashboardAggregationService!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
mockService = MockDashboardAggregationService()
|
||||||
|
sut = DashboardViewModel(dashboardService: mockService)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
sut = nil; mockService = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadDashboardData_Success() async {
|
||||||
|
mockService.fetchDashboardDataResult = .success(expectedData)
|
||||||
|
await sut.loadDashboardData()
|
||||||
|
XCTAssertFalse(sut.isLoading)
|
||||||
|
XCTAssertEqual(sut.todayStats.completedTasks, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:`@MainActor` + `async` 测试方法 + setUp/tearDown 重置。
|
||||||
|
|
||||||
|
### 3. Mock 数据工厂 — 静态 `.mock()` 方法
|
||||||
|
|
||||||
|
```swift
|
||||||
|
extension TaskModel {
|
||||||
|
static func mock(id: Int = 1, status: TaskStatus = .todo) -> TaskModel {
|
||||||
|
TaskModel(id: id, title: "Mock Task", status: status, ...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TaskListResponse {
|
||||||
|
static var mock: TaskListResponse {
|
||||||
|
TaskListResponse(tasks: [.mock(id: 1), .mock(id: 2)], total: 2, page: 1, pageSize: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 模型解码测试 — JSON → Model
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func testTaskModel_DecodesFromJSON() throws {
|
||||||
|
let json = """
|
||||||
|
{ "id": 123, "status": "in_progress", "priority": "high", ... }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let task = try decoder.decode(TaskModel.self, from: json)
|
||||||
|
XCTAssertEqual(task.status, .inProgress)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. SwiftUI 视图测试 — ViewInspector
|
||||||
|
|
||||||
|
```swift
|
||||||
|
extension EnhancedStatsSection: Inspectable {}
|
||||||
|
|
||||||
|
func testStatsSection_DisplaysCorrectValues() throws {
|
||||||
|
let view = EnhancedStatsSection(stats: .mock)
|
||||||
|
let text = try view.inspect().find(text: "5")
|
||||||
|
XCTAssertNotNil(text)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **@MainActor** — ViewModel 测试必须在主线程
|
||||||
|
2. **Mock 所有依赖** — 协议抽象 + Result 注入
|
||||||
|
3. **async/await** — 避免 XCTestExpectation 回调
|
||||||
|
4. **数据工厂** — `.mock()` 静态方法,参数带默认值
|
||||||
|
5. **隔离测试** — setUp/tearDown 重置所有状态
|
||||||
|
6. **命名** — `test<Method>_<Scenario>` 格式
|
||||||
|
|
||||||
|
## Xcode 快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 操作 |
|
||||||
|
|--------|------|
|
||||||
|
| `Cmd + U` | 运行所有测试 |
|
||||||
|
| `Ctrl + Opt + Cmd + U` | 运行当前测试方法 |
|
||||||
|
| `Ctrl + Opt + Cmd + G` | 重新运行上次测试 |
|
||||||
|
| `Cmd + 6` | Test Navigator |
|
||||||
8
plugins/dotfiles-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/dotfiles-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "dotfiles-plugin",
|
||||||
|
"description": "macOS 新机快速部署。用于 dotfiles 配置管理、install.sh 脚本维护、Claude Code 插件配置、MCP Server 配置。当用户提到新机部署、dotfiles、环境配置相关任务时自动激活。",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
469
plugins/dotfiles-plugin/skills/SKILL.md
Normal file
469
plugins/dotfiles-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
---
|
||||||
|
name: dotfiles
|
||||||
|
description: macOS 新机快速部署。用于 dotfiles 配置管理、install.sh 脚本维护、Claude Code 插件配置、MCP Server 配置。当用户提到新机部署、dotfiles、环境配置相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# dotfiles - macOS 新机快速部署
|
||||||
|
|
||||||
|
自动化 macOS 开发环境配置,包括 Homebrew、Claude Code、插件、MCP Server 等。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
1. **配置文件管理** - dotfiles 符号链接、Git 配置同步
|
||||||
|
2. **Claude Code 配置** - 插件批量安装、MCP Server 配置模板
|
||||||
|
3. **开发工具安装** - Homebrew、mise、SSH 配置
|
||||||
|
4. **环境一致性** - 多台电脑配置同步
|
||||||
|
|
||||||
|
## 子文档索引
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| [config-formats.md](docs/config-formats.md) | 配置文件格式规范(必读) |
|
||||||
|
| [testing.md](docs/testing.md) | 部署验证与测试方法 |
|
||||||
|
| [troubleshooting.md](docs/troubleshooting.md) | 常见问题与解决方案 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 克隆 dotfiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@gitea.pipexerp.com:huangjun/dotfiles.git ~/.dotfiles
|
||||||
|
cd ~/.dotfiles
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 执行安装脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:
|
||||||
|
- 需要 sudo 权限(安装 Homebrew)
|
||||||
|
- 需要手动输入密码(非交互式 SSH 无法自动化)
|
||||||
|
- 首次运行约 10-15 分钟
|
||||||
|
|
||||||
|
### 3. 配置 MCP Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 复制环境变量模板
|
||||||
|
cp ~/.dotfiles/claude/.env.example ~/.dotfiles/claude/.env
|
||||||
|
|
||||||
|
# 2. 编辑填入真实 Token
|
||||||
|
vim ~/.dotfiles/claude/.env
|
||||||
|
|
||||||
|
# 3. 删除现有 settings.json(如果已存在)
|
||||||
|
rm ~/.claude/settings.json
|
||||||
|
|
||||||
|
# 4. 重新运行 install.sh 生成配置
|
||||||
|
cd ~/.dotfiles && ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.dotfiles/
|
||||||
|
├── install.sh # 主安装脚本
|
||||||
|
├── .zshrc # Zsh 配置
|
||||||
|
├── .gitconfig # Git 配置
|
||||||
|
├── Brewfile # Homebrew 软件清单
|
||||||
|
├── claude/
|
||||||
|
│ ├── plugins-list.yaml # Claude Code 插件清单
|
||||||
|
│ ├── settings.json.template # MCP Server 配置模板
|
||||||
|
│ ├── .env.example # 环境变量示例
|
||||||
|
│ └── .env # 实际环境变量(不提交)
|
||||||
|
├── claude-skills/ # Claude Code 技能目录
|
||||||
|
└── mise/
|
||||||
|
└── config.toml # mise 全局配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键组件说明
|
||||||
|
|
||||||
|
### 1. plugins-list.yaml
|
||||||
|
|
||||||
|
**作用**:Claude Code 插件批量安装清单
|
||||||
|
|
||||||
|
**格式要求**(⚠️ 必须遵守):
|
||||||
|
```yaml
|
||||||
|
# ✅ 正确格式 - 统一 plugins: 段落
|
||||||
|
plugins:
|
||||||
|
- name: context7
|
||||||
|
marketplace: claude-plugins-official
|
||||||
|
description: 文档检索与上下文管理
|
||||||
|
|
||||||
|
- name: req-plugin
|
||||||
|
marketplace: claude-marketplace
|
||||||
|
description: 需求工作流管理
|
||||||
|
|
||||||
|
# 市场源 URL 配置
|
||||||
|
marketplace:
|
||||||
|
claude-marketplace:
|
||||||
|
url: git@gitea.pipexerp.com:huangjun/claude-marketplace.git
|
||||||
|
type: git
|
||||||
|
```
|
||||||
|
|
||||||
|
**常见错误**:
|
||||||
|
```yaml
|
||||||
|
# ❌ 错误格式 - 分段格式(install.sh 无法解析)
|
||||||
|
official_plugins:
|
||||||
|
- name: context7
|
||||||
|
marketplace: claude-plugins-official
|
||||||
|
|
||||||
|
private_plugins:
|
||||||
|
- name: req-plugin
|
||||||
|
marketplace: claude-marketplace
|
||||||
|
```
|
||||||
|
|
||||||
|
**详细规范**:见 [config-formats.md](docs/config-formats.md#plugins-listyaml)
|
||||||
|
|
||||||
|
### 2. settings.json.template
|
||||||
|
|
||||||
|
**作用**:MCP Server 配置模板,由 install.sh 使用 envsubst 渲染
|
||||||
|
|
||||||
|
**格式要求**(⚠️ 必须遵守):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ai-proj": {
|
||||||
|
"command": "node",
|
||||||
|
"args": [
|
||||||
|
"${HOME}/coding/qiudl/new-ai-proj/mcp-task-bridge/dist/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"TASK_API_BASE": "${AI_PROJ_API_BASE}",
|
||||||
|
"TASK_API_TOKEN": "${AI_PROJ_API_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**占位符格式**:
|
||||||
|
- ✅ 使用 `${VAR}` (envsubst 兼容)
|
||||||
|
- ❌ 禁止使用 `{{VAR}}` (envsubst 不支持)
|
||||||
|
|
||||||
|
**详细规范**:见 [config-formats.md](docs/config-formats.md#settingsjsontemplate)
|
||||||
|
|
||||||
|
### 3. .env.example
|
||||||
|
|
||||||
|
**作用**:环境变量示例文件,用户复制为 `.env` 后填入真实值
|
||||||
|
|
||||||
|
**必需变量**:
|
||||||
|
```bash
|
||||||
|
# ai-proj MCP Server
|
||||||
|
AI_PROJ_API_BASE=https://ai.pipexerp.com/api/v1
|
||||||
|
AI_PROJ_API_TOKEN=your_token_here
|
||||||
|
|
||||||
|
# WPS MCP Server
|
||||||
|
WPS_APP_ID=your_wps_app_id
|
||||||
|
WPS_APP_KEY=your_wps_app_key
|
||||||
|
|
||||||
|
# 飞书 MCP Server
|
||||||
|
FEISHU_APP_ID=your_feishu_app_id
|
||||||
|
FEISHU_APP_SECRET=your_feishu_secret
|
||||||
|
|
||||||
|
# 工作区路径(可选,默认 ~/workspace)
|
||||||
|
WORKSPACE=~/coding/qiudl
|
||||||
|
```
|
||||||
|
|
||||||
|
**检查完整性**:
|
||||||
|
```bash
|
||||||
|
# .env.example 必须包含 settings.json.template 中的所有 ${VAR}
|
||||||
|
diff <(grep -oE '\${[A-Z_]+}' ~/.dotfiles/claude/settings.json.template | sort -u) \
|
||||||
|
<(grep -oE '^[A-Z_]+=' ~/.dotfiles/claude/.env.example | sed 's/=$//' | sort)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## install.sh 工作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 检查/安装 Homebrew
|
||||||
|
└─ 需要 sudo 密码(手动输入)
|
||||||
|
|
||||||
|
2. 安装 Homebrew 软件
|
||||||
|
└─ 从 Brewfile 读取软件列表
|
||||||
|
|
||||||
|
3. 创建符号链接
|
||||||
|
├─ ~/.zshrc -> ~/.dotfiles/.zshrc
|
||||||
|
├─ ~/.gitconfig -> ~/.dotfiles/.gitconfig
|
||||||
|
└─ ~/.config/mise/config.toml -> ~/.dotfiles/mise/config.toml
|
||||||
|
|
||||||
|
4. 配置 Claude Code 插件
|
||||||
|
├─ 添加私有市场源(claude-marketplace)
|
||||||
|
└─ 批量安装 plugins-list.yaml 中的插件
|
||||||
|
|
||||||
|
5. 渲染 MCP 配置
|
||||||
|
├─ 检查 envsubst 是否安装
|
||||||
|
├─ 加载 ~/.dotfiles/claude/.env
|
||||||
|
└─ 渲染 settings.json.template -> ~/.claude/settings.json
|
||||||
|
|
||||||
|
6. 创建本地配置
|
||||||
|
└─ ~/.zshrc.local (MY_BRANCH, OTHER_BRANCH)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证部署结果
|
||||||
|
|
||||||
|
### 快速检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 检查符号链接
|
||||||
|
ls -la ~ | grep -E '\.zshrc|\.gitconfig'
|
||||||
|
|
||||||
|
# 2. 检查 Homebrew
|
||||||
|
which brew
|
||||||
|
brew --version
|
||||||
|
|
||||||
|
# 3. 检查 Claude Code
|
||||||
|
which claude
|
||||||
|
claude --version
|
||||||
|
|
||||||
|
# 4. 检查 MCP 配置
|
||||||
|
cat ~/.claude/settings.json | grep -E 'mcpServers|ai-proj'
|
||||||
|
|
||||||
|
# 5. 检查插件
|
||||||
|
claude /plugin list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整验证
|
||||||
|
|
||||||
|
运行测试脚本(基于 REQ-20260220-0002 回归测试经验):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.dotfiles/scripts/verify-deployment.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试内容**:
|
||||||
|
- TC-01: 私有市场源自动添加
|
||||||
|
- TC-02: plugins-list.yaml 格式验证
|
||||||
|
- TC-03: 插件批量安装验证
|
||||||
|
- TC-04: settings.json.template 格式验证
|
||||||
|
- TC-05: .env.example 完整性检查
|
||||||
|
- TC-06: .env 在 .gitignore 中
|
||||||
|
- TC-07: settings.json 模板渲染测试
|
||||||
|
- TC-08: 完整 install.sh 执行测试
|
||||||
|
|
||||||
|
详见 [testing.md](docs/testing.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. install.sh 执行时提示需要 sudo 密码
|
||||||
|
|
||||||
|
**原因**:Homebrew 安装需要管理员权限
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
- 本地执行:直接输入密码
|
||||||
|
- 远程 SSH:无法自动化,需用户在本地终端执行
|
||||||
|
|
||||||
|
### 2. envsubst 命令未找到
|
||||||
|
|
||||||
|
**原因**:envsubst 由 gettext 包提供,install.sh 会自动安装
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
```bash
|
||||||
|
brew install gettext
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 插件安装失败
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 市场源未添加
|
||||||
|
- SSH 密钥未配置(私有仓库)
|
||||||
|
- 插件名称错误
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
```bash
|
||||||
|
# 1. 检查市场源
|
||||||
|
ls ~/.claude/marketplaces/
|
||||||
|
|
||||||
|
# 2. 手动添加市场源
|
||||||
|
claude /plugin marketplace add git@gitea.pipexerp.com:huangjun/claude-marketplace.git
|
||||||
|
|
||||||
|
# 3. 配置 SSH 密钥
|
||||||
|
cat ~/.ssh/config # 检查 gitea.pipexerp.com 配置
|
||||||
|
ssh -T git@gitea.pipexerp.com # 测试 SSH 连接
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. settings.json 渲染后仍有 ${VAR}
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- .env 文件缺少对应变量
|
||||||
|
- 变量名拼写错误
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
```bash
|
||||||
|
# 检查 .env 是否包含所有必需变量
|
||||||
|
diff <(grep -oE '\${[A-Z_]+}' ~/.dotfiles/claude/settings.json.template | sed 's/[${}]//g' | sort -u) \
|
||||||
|
<(grep -oE '^[A-Z_]+=' ~/.dotfiles/claude/.env | sed 's/=.*//' | sort)
|
||||||
|
```
|
||||||
|
|
||||||
|
更多问题见 [troubleshooting.md](docs/troubleshooting.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 版本控制
|
||||||
|
|
||||||
|
**提交内容**:
|
||||||
|
- ✅ 配置模板(.env.example, settings.json.template)
|
||||||
|
- ✅ 安装脚本(install.sh)
|
||||||
|
- ✅ 插件清单(plugins-list.yaml)
|
||||||
|
- ❌ 敏感信息(.env, *.pem, *.key)
|
||||||
|
|
||||||
|
**检查 .gitignore**:
|
||||||
|
```bash
|
||||||
|
# 确保以下条目存在
|
||||||
|
grep -E '^\\.env$|^claude/\\.env$|^\\.pem$' ~/.dotfiles/.gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 多机同步
|
||||||
|
|
||||||
|
**场景**:澳洲电脑 + 中国电脑 + 成都 Mac Mini
|
||||||
|
|
||||||
|
**策略**:
|
||||||
|
```bash
|
||||||
|
# 1. 统一配置由 Git 同步
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 2. 机器特定配置写入 ~/.zshrc.local
|
||||||
|
echo "MY_BRANCH=au-dev" >> ~/.zshrc.local
|
||||||
|
echo "OTHER_BRANCH=cn-dev" >> ~/.zshrc.local
|
||||||
|
|
||||||
|
# 3. .zshrc 自动加载 .zshrc.local
|
||||||
|
[ -f ~/.zshrc.local ] && source ~/.zshrc.local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 测试驱动配置
|
||||||
|
|
||||||
|
**修改配置文件前**:
|
||||||
|
1. 在测试机上验证(如成都 Mac Mini)
|
||||||
|
2. 运行回归测试脚本
|
||||||
|
3. 确认所有测试通过后再提交
|
||||||
|
|
||||||
|
**示例工作流**(基于 REQ-20260220-0002):
|
||||||
|
```bash
|
||||||
|
# 1. 修改配置
|
||||||
|
vim ~/.dotfiles/claude/plugins-list.yaml
|
||||||
|
|
||||||
|
# 2. 提交到 Git
|
||||||
|
git add claude/plugins-list.yaml
|
||||||
|
git commit -m "fix: 统一 plugins-list.yaml 格式"
|
||||||
|
|
||||||
|
# 3. 推送到远程
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# 4. 在测试机验证
|
||||||
|
ssh chengdu "cd ~/.dotfiles && git pull && ./scripts/verify-deployment.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
| 技能 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `ops-tools` | 服务器运维工具 |
|
||||||
|
| `dev-test` | 软件测试方法论 |
|
||||||
|
| `req` | 需求工作流管理 |
|
||||||
|
| `skill-manager` | 技能自我进化管理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 经验教训(REQ-20260220-0002)
|
||||||
|
|
||||||
|
### 问题1: plugins-list.yaml 格式不兼容
|
||||||
|
|
||||||
|
**现象**:install.sh 无法解析插件列表
|
||||||
|
|
||||||
|
**原因**:使用了分段格式(official_plugins/private_plugins),install.sh 只解析 `plugins:` 段落
|
||||||
|
|
||||||
|
**解决**:统一使用 `plugins:` 段落格式
|
||||||
|
|
||||||
|
**预防**:
|
||||||
|
```bash
|
||||||
|
# 格式验证脚本
|
||||||
|
grep -E '^official_plugins:|^private_plugins:' ~/.dotfiles/claude/plugins-list.yaml && \
|
||||||
|
echo "❌ 检测到旧格式" || echo "✅ 格式正确"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题2: settings.json.template 占位符错误
|
||||||
|
|
||||||
|
**现象**:envsubst 渲染后仍有 {{VAR}}
|
||||||
|
|
||||||
|
**原因**:使用了 {{VAR}} 格式,envsubst 只支持 ${VAR}
|
||||||
|
|
||||||
|
**解决**:全局替换 `{{` -> `${`, `}}` -> `}`
|
||||||
|
|
||||||
|
**预防**:
|
||||||
|
```bash
|
||||||
|
# 检查占位符格式
|
||||||
|
grep '{{' ~/.dotfiles/claude/settings.json.template && \
|
||||||
|
echo "❌ 使用了不兼容格式" || echo "✅ 格式正确"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题3: .env.example 不完整
|
||||||
|
|
||||||
|
**现象**:渲染后 settings.json 包含未替换的 ${VAR}
|
||||||
|
|
||||||
|
**原因**:.env.example 缺少某些变量定义
|
||||||
|
|
||||||
|
**解决**:确保 .env.example 包含 settings.json.template 中的所有变量
|
||||||
|
|
||||||
|
**预防**:
|
||||||
|
```bash
|
||||||
|
# 完整性检查脚本(见 scripts/check-env-completeness.sh)
|
||||||
|
~/.dotfiles/scripts/check-env-completeness.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题4: .env 未加入 .gitignore
|
||||||
|
|
||||||
|
**现象**:敏感 Token 被提交到 Git
|
||||||
|
|
||||||
|
**原因**:.gitignore 缺少 .env 条目
|
||||||
|
|
||||||
|
**解决**:添加 `.env` 和 `claude/.env` 到 .gitignore
|
||||||
|
|
||||||
|
**预防**:
|
||||||
|
```bash
|
||||||
|
# Git 提交前 hook 检查
|
||||||
|
git diff --cached --name-only | grep -E '\\.env$' && \
|
||||||
|
echo "⚠️ 警告:尝试提交 .env 文件" && exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署检查清单
|
||||||
|
|
||||||
|
部署到新机器前,确认以下事项:
|
||||||
|
|
||||||
|
- [ ] dotfiles 仓库已克隆到 ~/.dotfiles
|
||||||
|
- [ ] SSH 密钥已配置(Gitea 访问)
|
||||||
|
- [ ] 用户有 sudo 权限
|
||||||
|
- [ ] 已复制 .env.example 为 .env 并填入真实 Token
|
||||||
|
- [ ] plugins-list.yaml 格式正确(统一 plugins: 段落)
|
||||||
|
- [ ] settings.json.template 使用 ${VAR} 格式
|
||||||
|
- [ ] .env 在 .gitignore 中
|
||||||
|
- [ ] 网络可访问 Homebrew、Gitea、MCP API
|
||||||
|
|
||||||
|
部署后验证:
|
||||||
|
|
||||||
|
- [ ] 运行 `~/.dotfiles/scripts/verify-deployment.sh`
|
||||||
|
- [ ] 检查所有测试用例通过
|
||||||
|
- [ ] 手动验证 Claude Code 能连接 MCP Server
|
||||||
|
- [ ] 手动验证插件已正确安装
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**更新时间**: 2026-02-20
|
||||||
|
**维护者**: qiudl
|
||||||
|
**数据来源**: REQ-20260220-0002 成都机器回归测试
|
||||||
14
plugins/doubao-voice-plugin/.claude-plugin/plugin.json
Normal file
14
plugins/doubao-voice-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "doubao-voice-plugin",
|
||||||
|
"description": "Doubao (豆包) Voice API integration for TTS and ASR",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": "doubao-voice",
|
||||||
|
"path": "./skills/SKILL.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
54
plugins/doubao-voice-plugin/.gitignore
vendored
Normal file
54
plugins/doubao-voice-plugin/.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# 音频文件(生成的测试输出)
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.pcm
|
||||||
|
|
||||||
|
# 测试脚本(仅本地使用)
|
||||||
|
scripts/test_*.py
|
||||||
|
scripts/check_credentials.py
|
||||||
|
scripts/README_TEST.md
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# 环境配置(包含凭证的本地文件)
|
||||||
|
setup_env.local.sh
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# 测试生成的文件
|
||||||
|
*.log
|
||||||
|
test_output/
|
||||||
201
plugins/doubao-voice-plugin/DEPLOY.md
Normal file
201
plugins/doubao-voice-plugin/DEPLOY.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# 部署指南
|
||||||
|
|
||||||
|
## 在另一台电脑上使用这个 Skill
|
||||||
|
|
||||||
|
### ✅ 可以直接使用吗?
|
||||||
|
|
||||||
|
**大部分功能可以直接使用!** 但需要做一些简单的配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 部署步骤
|
||||||
|
|
||||||
|
### 1️⃣ 将插件复制到新电脑
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式1: 从Git克隆
|
||||||
|
git clone <repo-url> doubao-voice-plugin
|
||||||
|
|
||||||
|
# 方式2: 复制文件夹
|
||||||
|
cp -r doubao-voice-plugin /path/to/new/location
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2️⃣ 安装依赖
|
||||||
|
|
||||||
|
**核心依赖** (必需):
|
||||||
|
```bash
|
||||||
|
pip3 install requests
|
||||||
|
```
|
||||||
|
|
||||||
|
**可选依赖** (仅用voice_converter_sdk.py时需要):
|
||||||
|
```bash
|
||||||
|
pip3 install volcengine
|
||||||
|
```
|
||||||
|
|
||||||
|
**检查是否安装成功**:
|
||||||
|
```bash
|
||||||
|
python3 -c "import requests; print('✅ requests 已安装')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3️⃣ 配置凭证
|
||||||
|
|
||||||
|
创建本地配置文件:
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
cp setup_env.local.sh.example setup_env.local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `setup_env.local.sh`,填入您的火山引擎凭证:
|
||||||
|
```bash
|
||||||
|
export DOUBAO_APP_ID="your_app_id"
|
||||||
|
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4️⃣ 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 加载环境变量
|
||||||
|
source scripts/setup_env.local.sh
|
||||||
|
|
||||||
|
# 文字转语音
|
||||||
|
python3 scripts/voice_converter.py tts "你好世界" -o hello.mp3
|
||||||
|
|
||||||
|
# 语音转文字(需先启用ASR服务)
|
||||||
|
python3 scripts/voice_converter.py asr audio.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 系统要求
|
||||||
|
|
||||||
|
| 需求 | 版本 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Python** | 3.6+ | ✅ 必需 |
|
||||||
|
| **requests** | 任意版本 | ✅ 必需 |
|
||||||
|
| **volcengine** | 任意版本 | ⚠️ 可选 |
|
||||||
|
| **操作系统** | Linux/Mac/Windows | ✅ 都支持 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 常见问题
|
||||||
|
|
||||||
|
### Q: 错误 "ModuleNotFoundError: No module named 'requests'"
|
||||||
|
**解决**:
|
||||||
|
```bash
|
||||||
|
pip3 install requests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 错误 "DOUBAO_APP_ID not found"
|
||||||
|
**解决**:
|
||||||
|
```bash
|
||||||
|
# 检查环境变量
|
||||||
|
echo $DOUBAO_APP_ID
|
||||||
|
|
||||||
|
# 如果为空,重新加载配置
|
||||||
|
source setup_env.local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 为什么 ASR 不工作?
|
||||||
|
**原因**: 需要在火山引擎控制台启用 ASR 服务
|
||||||
|
**解决**: 访问 https://console.volcengine.com/speech/service,启用语音识别服务
|
||||||
|
|
||||||
|
### Q: 可以在 Windows 上使用吗?
|
||||||
|
**可以!** 但环境变量设置方式不同:
|
||||||
|
|
||||||
|
```batch
|
||||||
|
REM Windows CMD
|
||||||
|
set DOUBAO_APP_ID=your_app_id
|
||||||
|
set DOUBAO_ACCESS_TOKEN=your_access_token
|
||||||
|
python scripts\voice_converter.py tts "你好" -o hello.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 PowerShell:
|
||||||
|
```powershell
|
||||||
|
$env:DOUBAO_APP_ID="your_app_id"
|
||||||
|
$env:DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||||
|
python scripts/voice_converter.py tts "你好" -o hello.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何在 Docker 中使用?
|
||||||
|
**Dockerfile 示例**:
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.9-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pip install requests
|
||||||
|
|
||||||
|
ENV DOUBAO_APP_ID=${DOUBAO_APP_ID}
|
||||||
|
ENV DOUBAO_ACCESS_TOKEN=${DOUBAO_ACCESS_TOKEN}
|
||||||
|
|
||||||
|
ENTRYPOINT ["python", "scripts/voice_converter.py"]
|
||||||
|
```
|
||||||
|
|
||||||
|
运行:
|
||||||
|
```bash
|
||||||
|
docker build -t doubao-voice .
|
||||||
|
docker run -e DOUBAO_APP_ID=xxx -e DOUBAO_ACCESS_TOKEN=xxx doubao-voice tts "你好"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 三种使用方式
|
||||||
|
|
||||||
|
### 方式 1: 命令行 (推荐简单使用)
|
||||||
|
```bash
|
||||||
|
python3 scripts/voice_converter.py tts "文本" -o output.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 2: Python 模块导入
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, 'scripts')
|
||||||
|
from voice_converter import DoubaoVoiceConverter
|
||||||
|
|
||||||
|
converter = DoubaoVoiceConverter()
|
||||||
|
converter.text_to_speech("你好世界", output_file="hello.mp3")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 3: Claude Code Skill (自动)
|
||||||
|
如果安装在 Claude Code 的 plugins 目录,会自动识别为 Skill:
|
||||||
|
```bash
|
||||||
|
# 用户说: "把这段话转成语音:你好世界"
|
||||||
|
# → 自动调用 TTS API
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 安全提示
|
||||||
|
|
||||||
|
✅ **推荐做法**:
|
||||||
|
- 凭证存储在 `.local` 文件中(不在 Git 中)
|
||||||
|
- 使用环境变量而不是硬编码
|
||||||
|
- 定期更新 Access Token
|
||||||
|
|
||||||
|
❌ **不要做**:
|
||||||
|
- 不要把凭证提交到 Git
|
||||||
|
- 不要在脚本中硬编码凭证
|
||||||
|
- 不要分享包含凭证的配置文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 最小化部署清单
|
||||||
|
|
||||||
|
```bash
|
||||||
|
✅ 复制文件夹
|
||||||
|
✅ pip install requests
|
||||||
|
✅ 复制并编辑 setup_env.local.sh
|
||||||
|
✅ source setup_env.local.sh
|
||||||
|
✅ python3 scripts/voice_converter.py tts "测试"
|
||||||
|
✅ 成功!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 如需帮助
|
||||||
|
|
||||||
|
1. 检查 README.md (用户文档)
|
||||||
|
2. 查看 skills/SKILL.md (API 文档)
|
||||||
|
3. 查看 STATUS.md (开发状态)
|
||||||
|
|
||||||
196
plugins/doubao-voice-plugin/GIT_GUIDE.md
Normal file
196
plugins/doubao-voice-plugin/GIT_GUIDE.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Git 提交指南
|
||||||
|
|
||||||
|
## 📋 提交清单
|
||||||
|
|
||||||
|
### ✅ 应该提交的文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git status # 确认以下文件已staged
|
||||||
|
|
||||||
|
应包含:
|
||||||
|
- .claude-plugin/plugin.json # 插件配置
|
||||||
|
- skills/SKILL.md # 技能文档
|
||||||
|
- scripts/voice_converter.py # 核心工具
|
||||||
|
- scripts/voice_converter_v2.py # 备选方案
|
||||||
|
- scripts/voice_converter_sdk.py # 备选方案
|
||||||
|
- scripts/check_credentials.py # 诊断工具
|
||||||
|
- scripts/test_services.py # 服务测试
|
||||||
|
- scripts/test_v3_debug.py # V3调试工具
|
||||||
|
- scripts/setup_env.sh # 示例脚本(占位符版本)
|
||||||
|
- scripts/setup_env.local.sh.example # 本地配置模板
|
||||||
|
- README.md # 用户文档
|
||||||
|
- STATUS.md # 开发状态
|
||||||
|
- .gitignore # Git忽略规则
|
||||||
|
- GIT_GUIDE.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 被自动忽略的文件(勿手动提交)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .gitignore 已配置,以下文件不会被提交:
|
||||||
|
- *.mp3, *.wav, *.pcm # 音频文件
|
||||||
|
- .DS_Store # 系统文件
|
||||||
|
- setup_env.local.sh # 本地凭证文件
|
||||||
|
- .env, .env.local # 环境变量文件
|
||||||
|
- __pycache__/ # Python缓存
|
||||||
|
- .vscode/, .idea/ # IDE配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 凭证管理 (重要!)
|
||||||
|
|
||||||
|
### 本地使用流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 基于模板创建本地配置文件
|
||||||
|
cd scripts
|
||||||
|
cp setup_env.local.sh.example setup_env.local.sh
|
||||||
|
|
||||||
|
# 2. 编辑本地文件,填入您的真实凭证
|
||||||
|
nano setup_env.local.sh # 或用您喜欢的编辑器
|
||||||
|
|
||||||
|
# 3. 本地使用时,source 本地文件
|
||||||
|
source setup_env.local.sh
|
||||||
|
|
||||||
|
# 4. 验证(注意:setup_env.local.sh 在 .gitignore 中)
|
||||||
|
git status # 应该看不到 setup_env.local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键安全要点
|
||||||
|
|
||||||
|
✅ **做这些**:
|
||||||
|
- 凭证存储在本地的 `.local` 文件中
|
||||||
|
- 凭证存储在环境变量中(不硬编码)
|
||||||
|
- 公开文件只包含占位符 `your_app_id`, `your_access_token`
|
||||||
|
- 定期检查 git status 确保没有凭证被暴露
|
||||||
|
|
||||||
|
❌ **不要做这些**:
|
||||||
|
- 不要把真实凭证提交到 Git
|
||||||
|
- 不要硬编码凭证在 Python 文件中
|
||||||
|
- 不要修改 .gitignore,让敏感文件被跟踪
|
||||||
|
- 不要分享包含凭证的 shell 脚本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 提交步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 确保您创建了本地配置文件
|
||||||
|
cd /Users/junhuang/coolbuy/claude-marketplace/plugins/doubao-voice-plugin/scripts
|
||||||
|
cp setup_env.local.sh.example setup_env.local.sh
|
||||||
|
# 编辑 setup_env.local.sh,填入您的凭证
|
||||||
|
|
||||||
|
# 2. 检查状态
|
||||||
|
cd ..
|
||||||
|
git status
|
||||||
|
|
||||||
|
# 3. 提交所有应提交的文件
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# 4. 验证没有凭证泄露
|
||||||
|
git diff --cached | grep -i "DOUBAO_APP_ID\|DOUBAO_ACCESS_TOKEN\|AKLT\|VOLCENGINE"
|
||||||
|
# 如果有输出,说明有凭证要被提交,请取消并修改
|
||||||
|
|
||||||
|
# 5. 提交
|
||||||
|
git commit -m "feat: Add Doubao Voice plugin with TTS/ASR support"
|
||||||
|
|
||||||
|
# 6. 再次检查
|
||||||
|
git show HEAD # 确认提交内容
|
||||||
|
|
||||||
|
# 7. 推送
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 验证清单
|
||||||
|
|
||||||
|
提交前,运行以下命令确认安全:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查是否有真实凭证在staged文件中
|
||||||
|
git diff --cached | grep -E "2288996168|LlDjcX-_UEnn4OW87iMorpXccQUilaHX|AKLTMGQ3"
|
||||||
|
# 正常情况下应该没有输出
|
||||||
|
|
||||||
|
# 检查 setup_env.local.sh 是否被忽略
|
||||||
|
git status | grep setup_env.local.sh
|
||||||
|
# 应该看不到这个文件
|
||||||
|
|
||||||
|
# 检查 .gitignore 配置是否正确
|
||||||
|
cat .gitignore | grep "setup_env.local"
|
||||||
|
# 应该看到这一行
|
||||||
|
|
||||||
|
# 查看即将提交的文件列表
|
||||||
|
git ls-files
|
||||||
|
# 确认关键文件都在其中,但不包含 setup_env.local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用说明(给其他用户)
|
||||||
|
|
||||||
|
在您发布插件后,其他用户应该:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 克隆插件
|
||||||
|
git clone <repo-url> doubao-voice-plugin
|
||||||
|
cd doubao-voice-plugin/scripts
|
||||||
|
|
||||||
|
# 2. 创建本地配置
|
||||||
|
cp setup_env.local.sh.example setup_env.local.sh
|
||||||
|
|
||||||
|
# 3. 编辑配置,填入他们自己的凭证
|
||||||
|
vim setup_env.local.sh
|
||||||
|
|
||||||
|
# 4. 配置环境变量
|
||||||
|
source setup_env.local.sh
|
||||||
|
|
||||||
|
# 5. 测试功能
|
||||||
|
python3 voice_converter.py tts "测试"
|
||||||
|
|
||||||
|
# 6. setup_env.local.sh 不会被版本控制跟踪
|
||||||
|
git status # 看不到 setup_env.local.sh ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: 我不小心提交了凭证怎么办?**
|
||||||
|
|
||||||
|
A: 立即执行:
|
||||||
|
```bash
|
||||||
|
# 从 Git 历史中移除敏感文件
|
||||||
|
git rm --cached scripts/setup_env.local.sh
|
||||||
|
git commit --amend -m "Remove sensitive file"
|
||||||
|
|
||||||
|
# 更改您的火山引擎 Access Token(出于安全考虑)
|
||||||
|
# 在控制台重新生成新的 token
|
||||||
|
```
|
||||||
|
|
||||||
|
**Q: 为什么需要 setup_env.local.sh.example?**
|
||||||
|
|
||||||
|
A: 这样其他用户可以看到配置文件应该包含哪些环境变量,而不会暴露任何真实凭证。
|
||||||
|
|
||||||
|
**Q: 可以把凭证放在 ~/.bashrc 里吗?**
|
||||||
|
|
||||||
|
A: 可以,但 setup_env.local.sh 更加灵活,易于项目专用配置。
|
||||||
|
|
||||||
|
**Q: 如何在 CI/CD 中使用敏感凭证?**
|
||||||
|
|
||||||
|
A: 在 CI/CD 平台(GitHub Actions, GitLab CI等)中使用 Secrets/Variables 功能,不要在代码中硬编码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
✅ **已完成的安全措施**:
|
||||||
|
1. ✓ .gitignore 配置了敏感文件忽略规则
|
||||||
|
2. ✓ setup_env.sh 改为占位符版本
|
||||||
|
3. ✓ 创建了 setup_env.local.sh.example 模板
|
||||||
|
4. ✓ 所有代码文件使用环境变量读取凭证
|
||||||
|
5. ✓ 提供了清晰的本地配置说明
|
||||||
|
|
||||||
|
现在可以安全地提交到 Git!🎉
|
||||||
182
plugins/doubao-voice-plugin/README.md
Normal file
182
plugins/doubao-voice-plugin/README.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# 豆包语音插件 (Doubao Voice Plugin)
|
||||||
|
|
||||||
|
火山引擎豆包语音API集成插件,支持文字转语音(TTS)和唱歌功能。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **✅ 语音合成 (TTS)**: 文字转语音,支持多种音色 - **已测试可用**
|
||||||
|
- **🎵 唱歌**: 让豆包唱歌,支持实时语音交互 - **已开通端到端大模型**
|
||||||
|
- **简单易用**: 命令行工具,一行命令即可使用
|
||||||
|
- **多种音色**: 支持女声/男声等多种基础音色
|
||||||
|
- **实时交互**: 支持与豆包进行实时对话和唱歌
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 获取API凭证
|
||||||
|
|
||||||
|
访问 [火山引擎控制台](https://console.volcengine.com/speech/app) 创建应用并获取:
|
||||||
|
- **App ID** (数字)
|
||||||
|
- **Access Token** (长字符串)
|
||||||
|
|
||||||
|
开通所需服务:
|
||||||
|
1. 在控制台勾选 **"语音合成"** 服务 (TTS)
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
**方式1: 使用配置脚本 (推荐)**
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
source setup_env.sh # 自动设置环境变量
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式2: 手动设置**
|
||||||
|
```bash
|
||||||
|
export DOUBAO_APP_ID="your_app_id"
|
||||||
|
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install requests --break-system-packages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 检查凭证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查凭证配置
|
||||||
|
python3 scripts/check_credentials.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 使用示例
|
||||||
|
|
||||||
|
#### TTS 文字转语音(命令行)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
|
||||||
|
# 基础用法 - ✅ 已测试可用
|
||||||
|
python3 voice_converter.py tts "你好,我是豆包语音助手" -o output.mp3
|
||||||
|
|
||||||
|
# 使用不同音色
|
||||||
|
python3 voice_converter.py tts "测试男声" -o male.mp3 -v BV701_V2_streaming
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 唱歌(命令行)🎵
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
|
||||||
|
# 让豆包唱歌
|
||||||
|
python3 singing.py sing "请唱一首关于春天的歌" -o spring.mp3
|
||||||
|
|
||||||
|
# 交互式唱歌模式(实时对话)
|
||||||
|
python3 singing.py interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python 代码方式
|
||||||
|
|
||||||
|
```python
|
||||||
|
# TTS - 文字转语音
|
||||||
|
from scripts.voice_converter import DoubaoVoiceConverter
|
||||||
|
|
||||||
|
converter = DoubaoVoiceConverter()
|
||||||
|
audio_file = converter.text_to_speech("你好,欢迎使用豆包", output_file="hello.mp3")
|
||||||
|
|
||||||
|
# 唱歌
|
||||||
|
import asyncio
|
||||||
|
from scripts.singing import DoubaoSinging
|
||||||
|
|
||||||
|
async def sing():
|
||||||
|
singing = DoubaoSinging()
|
||||||
|
audio_file = await singing.sing("请唱一首情歌", output_file="love_song.mp3")
|
||||||
|
|
||||||
|
asyncio.run(sing())
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自然语言调用
|
||||||
|
|
||||||
|
在 Claude Code 中可以使用自然语言调用:
|
||||||
|
|
||||||
|
**TTS 文字转语音**:
|
||||||
|
- "把这段话转成语音:你好世界"
|
||||||
|
- "用温柔女声合成语音"
|
||||||
|
- "用男声朗读这段文字"
|
||||||
|
|
||||||
|
**唱歌**:
|
||||||
|
- "请唱一首关于春天的歌"
|
||||||
|
- "唱一个温柔的摇篮曲"
|
||||||
|
- "开启与豆包的实时语音对话模式"
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```
|
||||||
|
用户: "帮我把'欢迎使用豆包语音'转成语音"
|
||||||
|
Claude: 调用TTS服务生成output.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 价格说明
|
||||||
|
|
||||||
|
### TTS (语音合成)
|
||||||
|
- 大模型并发版: 2000元/并发/月
|
||||||
|
- 按量付费: 按字符数计费
|
||||||
|
|
||||||
|
### 免费试用
|
||||||
|
新用户开通服务后可获得免费额度。
|
||||||
|
|
||||||
|
## 支持的音色
|
||||||
|
|
||||||
|
| 音色代码 | 描述 | 场景 | 状态 |
|
||||||
|
|---------|------|------|------|
|
||||||
|
| BV700_V2_streaming | 通用女声 | 通用场景 | ✅ V1 可用 |
|
||||||
|
| BV701_V2_streaming | 通用男声 | 通用场景 | ✅ V1 可用 |
|
||||||
|
| BV406_streaming | 温柔女声 | 客服、助手 | ✅ V1 可用 |
|
||||||
|
| BV158_streaming | 活泼女声 | 教育、娱乐 | ✅ V1 可用 |
|
||||||
|
| BV115_streaming | 磁性男声 | 新闻、播音 | ✅ V1 可用 |
|
||||||
|
|
||||||
|
**注意**: 豆包2.0高级音色需要使用V3 API,目前正在调试中。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### TTS 返回 "requested resource not granted"
|
||||||
|
**解决方法**: 在控制台勾选"语音合成"服务选项
|
||||||
|
|
||||||
|
### Authorization 头格式错误
|
||||||
|
确保使用 `Bearer;{token}` 格式(注意分号),而不是 `Bearer {token}`
|
||||||
|
|
||||||
|
### 环境变量未生效
|
||||||
|
```bash
|
||||||
|
# 检查环境变量
|
||||||
|
echo $DOUBAO_APP_ID
|
||||||
|
echo $DOUBAO_ACCESS_TOKEN
|
||||||
|
|
||||||
|
# 如果为空,重新设置
|
||||||
|
source setup_env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 版本说明
|
||||||
|
|
||||||
|
### V1 API (当前使用) ✅
|
||||||
|
- **状态**: 已测试,稳定可用
|
||||||
|
- **认证**: Bearer Token
|
||||||
|
- **音色**: 支持基础音色
|
||||||
|
- **推荐**: 日常使用推荐
|
||||||
|
|
||||||
|
### V3 API (豆包2.0) ⚠️
|
||||||
|
- **状态**: 调试中,存在 "get resource id empty" 问题
|
||||||
|
- **认证**: Bearer Token + Resource-Id
|
||||||
|
- **音色**: 支持豆包2.0高级音色
|
||||||
|
- **说明**: 需要联系火山引擎技术支持获取正确配置
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
- [官方文档](https://www.volcengine.com/docs/6561/1359369)
|
||||||
|
- [控制台](https://console.volcengine.com/speech/app)
|
||||||
|
- [计费说明](https://www.volcengine.com/docs/6561/1359370)
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本插件遵循 MIT 许可证。
|
||||||
|
|
||||||
|
## 作者
|
||||||
|
|
||||||
|
qiudl @ zhiyuncai.com
|
||||||
200
plugins/doubao-voice-plugin/STATUS.md
Normal file
200
plugins/doubao-voice-plugin/STATUS.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# 豆包语音插件 - 开发状态
|
||||||
|
|
||||||
|
**更新时间**: 2026-02-07
|
||||||
|
**版本**: 1.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 已完成功能
|
||||||
|
|
||||||
|
### 1. TTS (文字转语音) - 完全可用 ✅
|
||||||
|
|
||||||
|
**测试状态**: 通过
|
||||||
|
**API版本**: V1
|
||||||
|
**可用音色**:
|
||||||
|
- BV700_V2_streaming (通用女声)
|
||||||
|
- BV701_V2_streaming (通用男声)
|
||||||
|
- BV406_streaming (温柔女声)
|
||||||
|
- BV158_streaming (活泼女声)
|
||||||
|
- BV115_streaming (磁性男声)
|
||||||
|
|
||||||
|
**测试命令**:
|
||||||
|
```bash
|
||||||
|
source scripts/setup_env.sh
|
||||||
|
python3 scripts/voice_converter.py tts "你好世界" -o hello.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试结果**:
|
||||||
|
- ✅ HTTP 200 OK
|
||||||
|
- ✅ Code 3000 Success
|
||||||
|
- ✅ 成功生成 MP3 文件
|
||||||
|
- ✅ 音质正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 待完成功能
|
||||||
|
|
||||||
|
### 2. ASR (语音转文字) - 待启用服务
|
||||||
|
|
||||||
|
**问题**: Code 1001 - "requested resource not granted"
|
||||||
|
|
||||||
|
**原因**: ASR 服务未在火山引擎控制台正确启用
|
||||||
|
|
||||||
|
**解决步骤**:
|
||||||
|
1. 访问: https://console.volcengine.com/speech/service
|
||||||
|
2. 找到 "语音识别 (ASR)" 服务
|
||||||
|
3. 确保服务已启用并勾选必要选项
|
||||||
|
4. 等待服务生效(可能需要几分钟)
|
||||||
|
5. 重新测试
|
||||||
|
|
||||||
|
**测试命令** (服务启用后):
|
||||||
|
```bash
|
||||||
|
python3 scripts/voice_converter.py asr audio.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. V3 API / 豆包2.0音色 - 调试中
|
||||||
|
|
||||||
|
**问题**: Code 45000000 - "get resource id empty"
|
||||||
|
|
||||||
|
**已尝试的方法**:
|
||||||
|
- [x] Resource-Id header
|
||||||
|
- [x] X-Resource-Id header
|
||||||
|
- [x] resource_id query parameter
|
||||||
|
- [x] resource_id in app config
|
||||||
|
- [x] 多种 resource_id 值: volc.bigmodel.tts, volc.seed-tts.default, volc.tts.default
|
||||||
|
|
||||||
|
**当前状态**: 所有方法均返回相同错误
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
1. V3 API 可能需要不同的认证方式 (IAM签名)
|
||||||
|
2. 需要特殊的服务实例配置
|
||||||
|
3. Resource-Id 的获取或配置方法不正确
|
||||||
|
|
||||||
|
**建议**:
|
||||||
|
- 联系火山引擎技术支持获取 V3 API 正确配置方法
|
||||||
|
- 或继续使用 V1 API (已满足基本需求)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 项目文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/doubao-voice-plugin/
|
||||||
|
├── .claude-plugin/
|
||||||
|
│ └── plugin.json # 插件元数据
|
||||||
|
├── skills/
|
||||||
|
│ └── SKILL.md # 技能定义和文档
|
||||||
|
├── scripts/
|
||||||
|
│ ├── voice_converter.py # 主转换工具 (V1 API, 可用)
|
||||||
|
│ ├── voice_converter_v2.py # 手动签名版本 (待测试)
|
||||||
|
│ ├── voice_converter_sdk.py # SDK版本 (待测试)
|
||||||
|
│ ├── check_credentials.py # 凭证检查工具
|
||||||
|
│ ├── test_services.py # 服务状态测试
|
||||||
|
│ ├── test_v3_debug.py # V3 API 调试脚本
|
||||||
|
│ ├── setup_env.sh # 环境变量配置脚本
|
||||||
|
│ └── README_TEST.md # 测试报告
|
||||||
|
├── README.md # 用户文档
|
||||||
|
└── STATUS.md # 本文件 (开发状态)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 诊断工具
|
||||||
|
|
||||||
|
### 检查凭证配置
|
||||||
|
```bash
|
||||||
|
python3 scripts/check_credentials.py
|
||||||
|
```
|
||||||
|
显示当前环境变量配置状态
|
||||||
|
|
||||||
|
### 测试服务状态
|
||||||
|
```bash
|
||||||
|
python3 scripts/test_services.py
|
||||||
|
```
|
||||||
|
测试 TTS 和 ASR 服务是否可用
|
||||||
|
|
||||||
|
### V3 API 调试
|
||||||
|
```bash
|
||||||
|
python3 scripts/test_v3_debug.py
|
||||||
|
```
|
||||||
|
测试多种 V3 API 配置方式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 当前凭证配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOUBAO_APP_ID="your_app_id"
|
||||||
|
DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||||
|
|
||||||
|
# V3 可选配置 (暂不可用)
|
||||||
|
# DOUBAO_USE_V3="true"
|
||||||
|
# DOUBAO_RESOURCE_ID="volc.bigmodel.tts"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access Key 信息** (用于签名认证,暂未使用):
|
||||||
|
- Access Key ID: your_access_key_id
|
||||||
|
- Secret Access Key: your_secret_access_key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 下一步计划
|
||||||
|
|
||||||
|
### 立即可用
|
||||||
|
1. ✅ **使用 TTS 功能**
|
||||||
|
- 集成到应用中
|
||||||
|
- 测试不同音色
|
||||||
|
- 生产环境部署
|
||||||
|
|
||||||
|
### 短期目标 (1-3天)
|
||||||
|
2. ⚠️ **启用 ASR 服务**
|
||||||
|
- 在控制台启用服务
|
||||||
|
- 测试语音识别功能
|
||||||
|
- 完善错误处理
|
||||||
|
|
||||||
|
### 长期目标 (可选)
|
||||||
|
3. 🔄 **V3 API 支持**
|
||||||
|
- 联系火山引擎技术支持
|
||||||
|
- 获取正确的 Resource-Id 配置方法
|
||||||
|
- 支持豆包2.0高级音色
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 技术支持
|
||||||
|
|
||||||
|
### 火山引擎
|
||||||
|
- 文档: https://www.volcengine.com/docs/6561/1329505
|
||||||
|
- 控制台: https://console.volcengine.com/speech/app
|
||||||
|
- 服务管理: https://console.volcengine.com/speech/service
|
||||||
|
|
||||||
|
### 常见问题解决
|
||||||
|
1. **TTS 可用但 ASR 不可用**
|
||||||
|
- 检查控制台 ASR 服务是否启用
|
||||||
|
- 确认勾选了"语音识别"选项
|
||||||
|
|
||||||
|
2. **V3 API 持续报错**
|
||||||
|
- 暂时使用 V1 API
|
||||||
|
- 联系火山引擎技术支持
|
||||||
|
|
||||||
|
3. **认证失败**
|
||||||
|
- 检查环境变量是否正确设置
|
||||||
|
- 确认 Access Token 格式正确
|
||||||
|
- 注意 Authorization header 使用 `Bearer;{token}` (有分号)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 总结
|
||||||
|
|
||||||
|
**当前可用**: TTS (文字转语音) 功能完全可用,可以投入使用
|
||||||
|
|
||||||
|
**待解决**:
|
||||||
|
1. 在控制台启用 ASR 服务
|
||||||
|
2. (可选) 解决 V3 API 配置问题
|
||||||
|
|
||||||
|
**建议**: 先使用 V1 API 的 TTS 功能,满足基本语音合成需求。ASR 功能在控制台启用服务后即可使用。V3 API 的豆包2.0音色为可选功能,可以后续再解决。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated by Claude Code on 2026-02-07*
|
||||||
186
plugins/doubao-voice-plugin/scripts/README.md
Normal file
186
plugins/doubao-voice-plugin/scripts/README.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# 豆包语音工具使用指南
|
||||||
|
|
||||||
|
简单易用的豆包语音命令行工具,支持**文字转语音(TTS)**和**唱歌**。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 配置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 ~/.zshrc 或 ~/.bashrc 中添加
|
||||||
|
export DOUBAO_APP_ID="your_app_id"
|
||||||
|
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||||
|
|
||||||
|
# 使配置生效
|
||||||
|
source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install requests
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 📝 文字转语音 (TTS)
|
||||||
|
|
||||||
|
**基础用法:**
|
||||||
|
```bash
|
||||||
|
python voice_converter.py tts "你好,我是豆包语音助手"
|
||||||
|
```
|
||||||
|
|
||||||
|
**指定输出文件和音色:**
|
||||||
|
```bash
|
||||||
|
python voice_converter.py tts "欢迎使用豆包语音" -o welcome.mp3 -v BV701_V2_streaming
|
||||||
|
```
|
||||||
|
|
||||||
|
**可用音色:**
|
||||||
|
- `BV700_V2_streaming` - 通用女声(默认,推荐)
|
||||||
|
- `BV701_V2_streaming` - 通用男声
|
||||||
|
- `BV406_streaming` - 温柔女声
|
||||||
|
- `BV158_streaming` - 活泼女声
|
||||||
|
- `BV115_streaming` - 磁性男声
|
||||||
|
|
||||||
|
### 🎵 唱歌 (Singing)
|
||||||
|
|
||||||
|
**基础用法:**
|
||||||
|
```bash
|
||||||
|
python singing.py sing "请唱一首关于春天的歌"
|
||||||
|
```
|
||||||
|
|
||||||
|
**指定输出文件:**
|
||||||
|
```bash
|
||||||
|
python singing.py sing "唱一个温柔的摇篮曲" -o lullaby.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
**交互式模式(实时对话):**
|
||||||
|
```bash
|
||||||
|
python singing.py interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
在交互模式下可以自然地与豆包对话,要求她唱歌、讲故事等。输入 `quit` 退出。
|
||||||
|
|
||||||
|
## Python 代码调用
|
||||||
|
|
||||||
|
```python
|
||||||
|
# TTS - 文字转语音
|
||||||
|
from voice_converter import DoubaoVoiceConverter
|
||||||
|
|
||||||
|
converter = DoubaoVoiceConverter()
|
||||||
|
audio_file = converter.text_to_speech(
|
||||||
|
"你好,欢迎使用豆包语音",
|
||||||
|
output_file="hello.mp3",
|
||||||
|
voice_type="BV700_V2_streaming"
|
||||||
|
)
|
||||||
|
print(f"生成语音: {audio_file}")
|
||||||
|
|
||||||
|
# 唱歌
|
||||||
|
import asyncio
|
||||||
|
from singing import DoubaoSinging
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
singing = DoubaoSinging()
|
||||||
|
|
||||||
|
# 让豆包唱歌
|
||||||
|
audio_file = await singing.sing(
|
||||||
|
"请唱一首情歌",
|
||||||
|
output_file="love_song.mp3",
|
||||||
|
language="zh-CN"
|
||||||
|
)
|
||||||
|
print(f"唱歌完成: {audio_file}")
|
||||||
|
|
||||||
|
# 或启动交互模式
|
||||||
|
# await singing.interactive_singing()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
### 示例1:生成通知语音
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成女声通知
|
||||||
|
python voice_converter.py tts "您有一条新消息,请注意查收" -o notification.mp3
|
||||||
|
|
||||||
|
# 生成男声通知
|
||||||
|
python voice_converter.py tts "系统将在5分钟后进行维护" -o maintenance.mp3 -v BV701_V2_streaming
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例2:唱歌
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 让豆包唱一首情歌
|
||||||
|
python singing.py sing "请唱一首温柔的情歌" -o love_song.mp3
|
||||||
|
|
||||||
|
# 让豆包唱一首儿歌
|
||||||
|
python singing.py sing "唱一首欢快的儿歌" -o kids_song.mp3
|
||||||
|
|
||||||
|
# 启动交互式模式与豆包对话
|
||||||
|
python singing.py interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 常见错误
|
||||||
|
|
||||||
|
**1. 环境变量未设置**
|
||||||
|
```
|
||||||
|
❌ 错误: 请先设置环境变量:
|
||||||
|
export DOUBAO_APP_ID='your_app_id'
|
||||||
|
export DOUBAO_ACCESS_TOKEN='your_access_token'
|
||||||
|
```
|
||||||
|
**解决:** 确保已正确设置环境变量并 `source ~/.zshrc`
|
||||||
|
|
||||||
|
**2. API 调用失败**
|
||||||
|
```
|
||||||
|
❌ 错误: TTS 失败 (code: 4001): Invalid token
|
||||||
|
```
|
||||||
|
**解决:** 检查 Access Token 是否正确或已过期
|
||||||
|
|
||||||
|
## 技术参数
|
||||||
|
|
||||||
|
### 音频格式要求
|
||||||
|
|
||||||
|
**TTS 输出:**
|
||||||
|
- 格式:MP3
|
||||||
|
- 采样率:16000 Hz
|
||||||
|
- 声道:单声道
|
||||||
|
|
||||||
|
### API 限制
|
||||||
|
|
||||||
|
- **TTS**: 单次最长 5000 字符
|
||||||
|
- **并发限制**: 根据购买的并发数
|
||||||
|
|
||||||
|
## 在 Claude Code 中使用
|
||||||
|
|
||||||
|
在 Claude Code 中可以直接用自然语言调用:
|
||||||
|
|
||||||
|
**TTS - 文字转语音**:
|
||||||
|
```
|
||||||
|
"把这段话转成语音:你好世界"
|
||||||
|
"用温柔女声合成:欢迎光临"
|
||||||
|
```
|
||||||
|
|
||||||
|
**唱歌**:
|
||||||
|
```
|
||||||
|
"请唱一首关于春天的歌"
|
||||||
|
"唱一个温柔的摇篮曲"
|
||||||
|
"开启与豆包的实时语音对话模式"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 获取 API 凭证
|
||||||
|
|
||||||
|
1. 访问 [火山引擎控制台](https://console.volcengine.com/speech/app)
|
||||||
|
2. 创建应用
|
||||||
|
3. 获取 App ID 和 Access Token
|
||||||
|
4. 开通所需服务:
|
||||||
|
- 豆包语音合成模型2.0
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [火山引擎豆包语音文档](https://www.volcengine.com/docs/6561)
|
||||||
|
- [API 接口文档](https://www.volcengine.com/docs/6561/1096680)
|
||||||
|
- [计费说明](https://www.volcengine.com/docs/6561/1359370)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 豆包语音 API 环境变量配置(本地版本)
|
||||||
|
#
|
||||||
|
# 使用说明:
|
||||||
|
# 1. 复制本文件: cp setup_env.local.sh.example setup_env.local.sh
|
||||||
|
# 2. 编辑 setup_env.local.sh,填入您的真实凭证
|
||||||
|
# 3. 运行: source setup_env.local.sh
|
||||||
|
# 4. .gitignore 已配置忽略 setup_env.local.sh,所以您的凭证不会被提交到 Git
|
||||||
|
|
||||||
|
# ⚠️ 重要:请在下面填入您的真实凭证(仅本地使用)
|
||||||
|
export DOUBAO_APP_ID="your_app_id_here"
|
||||||
|
export DOUBAO_ACCESS_TOKEN="your_access_token_here"
|
||||||
|
|
||||||
|
# V3 API 配置 (可选,如需豆包2.0音色)
|
||||||
|
# export DOUBAO_USE_V3="true"
|
||||||
|
# export DOUBAO_RESOURCE_ID="volc.bigmodel.tts"
|
||||||
|
|
||||||
|
echo "✅ 豆包语音 API 环境变量已设置(本地配置)"
|
||||||
|
echo ""
|
||||||
|
echo "App ID: ${DOUBAO_APP_ID:0:10}..."
|
||||||
|
echo "Access Token: ${DOUBAO_ACCESS_TOKEN:0:20}..."
|
||||||
22
plugins/doubao-voice-plugin/scripts/setup_env.sh
Executable file
22
plugins/doubao-voice-plugin/scripts/setup_env.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 豆包语音 API 环境变量配置 (示例)
|
||||||
|
#
|
||||||
|
# ⚠️ 重要:这是示例脚本,包含占位符。
|
||||||
|
# 本地使用时,请参考 setup_env.local.sh.example 创建 setup_env.local.sh,
|
||||||
|
# 然后在其中填入您的真实凭证。.gitignore 已配置忽略 .local 文件。
|
||||||
|
|
||||||
|
export DOUBAO_APP_ID="your_app_id"
|
||||||
|
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||||
|
|
||||||
|
# V3 API 配置 (可选,如需豆包2.0音色)
|
||||||
|
# export DOUBAO_USE_V3="true"
|
||||||
|
# export DOUBAO_RESOURCE_ID="volc.bigmodel.tts"
|
||||||
|
|
||||||
|
echo "✅ 豆包语音 API 环境变量已设置"
|
||||||
|
echo ""
|
||||||
|
echo "App ID: $DOUBAO_APP_ID"
|
||||||
|
echo "Access Token: ${DOUBAO_ACCESS_TOKEN:0:20}..."
|
||||||
|
echo ""
|
||||||
|
echo "现在可以运行:"
|
||||||
|
echo " python3 voice_converter.py tts \"你好世界\" -o hello.mp3"
|
||||||
|
echo " python3 voice_converter.py asr audio.mp3 # 需先启用ASR服务"
|
||||||
327
plugins/doubao-voice-plugin/scripts/singing.py
Executable file
327
plugins/doubao-voice-plugin/scripts/singing.py
Executable file
@@ -0,0 +1,327 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
豆包唱歌工具
|
||||||
|
基于豆包端到端实时语音大模型,支持让豆包唱歌
|
||||||
|
使用WebSocket实时对话和生成音频
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import websockets
|
||||||
|
import struct
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# 连接级事件(不需要session_id)
|
||||||
|
CONNECTION_EVENTS = {1, 2, 50, 51, 52}
|
||||||
|
|
||||||
|
|
||||||
|
class DoubaoSinging:
|
||||||
|
"""豆包唱歌工具类"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 从环境变量读取配置
|
||||||
|
self.app_id = os.environ.get("DOUBAO_APP_ID")
|
||||||
|
self.access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||||
|
|
||||||
|
if not self.app_id or not self.access_token:
|
||||||
|
raise ValueError(
|
||||||
|
"请先设置环境变量:\n"
|
||||||
|
"export DOUBAO_APP_ID='your_app_id'\n"
|
||||||
|
"export DOUBAO_ACCESS_TOKEN='your_access_token'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 端到端实时语音WebSocket地址
|
||||||
|
self.ws_url = "wss://openspeech.bytedance.com/api/v3/realtime/dialogue"
|
||||||
|
self.app_key = "PlgvMymc7f3tQnJ6" # 固定值
|
||||||
|
self.resource_id = "volc.speech.dialog" # 固定值
|
||||||
|
|
||||||
|
def _build_message(self, event_id: int, payload: dict = None, session_id: str = None) -> bytes:
|
||||||
|
"""
|
||||||
|
构建二进制消息
|
||||||
|
|
||||||
|
协议格式:
|
||||||
|
- header (4 bytes)
|
||||||
|
- event_id (4 bytes, big-endian)
|
||||||
|
- [session_id_len (4 bytes) + session_id (variable)] -- 仅非连接级事件
|
||||||
|
- payload_len (4 bytes, big-endian)
|
||||||
|
- payload (variable, JSON)
|
||||||
|
"""
|
||||||
|
buf = bytearray()
|
||||||
|
|
||||||
|
# Header (4 bytes)
|
||||||
|
buf.append(0x11) # version=1, header_size=1
|
||||||
|
buf.append(0x14) # FULL_CLIENT_REQUEST(0x1) + WITH_EVENT(0x4)
|
||||||
|
buf.append(0x10) # JSON serialization, no compression
|
||||||
|
buf.append(0x00) # reserved
|
||||||
|
|
||||||
|
# Event ID
|
||||||
|
buf.extend(struct.pack('>I', event_id))
|
||||||
|
|
||||||
|
# Session ID (required for non-connection events)
|
||||||
|
if event_id not in CONNECTION_EVENTS:
|
||||||
|
sid_bytes = (session_id or "").encode('utf-8')
|
||||||
|
buf.extend(struct.pack('>I', len(sid_bytes)))
|
||||||
|
buf.extend(sid_bytes)
|
||||||
|
|
||||||
|
# Payload
|
||||||
|
if payload:
|
||||||
|
payload_bytes = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||||||
|
else:
|
||||||
|
payload_bytes = b'{}'
|
||||||
|
buf.extend(struct.pack('>I', len(payload_bytes)))
|
||||||
|
buf.extend(payload_bytes)
|
||||||
|
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
def _parse_response(self, data: bytes) -> dict:
|
||||||
|
"""
|
||||||
|
解析服务端二进制消息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys: msg_type, event_id, session_id, payload, payload_bytes
|
||||||
|
"""
|
||||||
|
result = {"raw": data}
|
||||||
|
if len(data) < 4:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Header
|
||||||
|
msg_type = (data[1] >> 4) & 0x0F
|
||||||
|
flags = data[1] & 0x0F
|
||||||
|
result["msg_type"] = msg_type
|
||||||
|
|
||||||
|
offset = 4
|
||||||
|
|
||||||
|
# Event ID (if WITH_EVENT flag)
|
||||||
|
if flags & 0x04 and len(data) >= offset + 4:
|
||||||
|
event_id = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||||
|
result["event_id"] = event_id
|
||||||
|
offset += 4
|
||||||
|
|
||||||
|
# Connect ID for connection events (50, 51, 52)
|
||||||
|
if event_id in {50, 51, 52} and len(data) >= offset + 4:
|
||||||
|
cid_len = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||||
|
offset += 4
|
||||||
|
if len(data) >= offset + cid_len:
|
||||||
|
result["connect_id"] = data[offset:offset + cid_len].decode('utf-8', errors='ignore')
|
||||||
|
offset += cid_len
|
||||||
|
# Session ID for session-level events
|
||||||
|
elif event_id not in CONNECTION_EVENTS and len(data) >= offset + 4:
|
||||||
|
sid_len = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||||
|
offset += 4
|
||||||
|
if len(data) >= offset + sid_len:
|
||||||
|
result["session_id"] = data[offset:offset + sid_len].decode('utf-8', errors='ignore')
|
||||||
|
offset += sid_len
|
||||||
|
|
||||||
|
# Payload
|
||||||
|
if len(data) >= offset + 4:
|
||||||
|
payload_len = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||||
|
offset += 4
|
||||||
|
if len(data) >= offset + payload_len:
|
||||||
|
payload_raw = data[offset:offset + payload_len]
|
||||||
|
result["payload_bytes"] = payload_raw
|
||||||
|
# Audio-only responses (msg_type 0xB) have raw audio
|
||||||
|
if msg_type == 0x0B:
|
||||||
|
result["is_audio"] = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result["payload"] = json.loads(payload_raw.decode('utf-8'))
|
||||||
|
except:
|
||||||
|
result["payload_text"] = payload_raw.decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def sing(
|
||||||
|
self,
|
||||||
|
song_request: str,
|
||||||
|
output_file: str = "singing_output.mp3",
|
||||||
|
language: str = "zh-CN",
|
||||||
|
model: str = "1.2.1.0"
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
让豆包唱歌
|
||||||
|
|
||||||
|
Args:
|
||||||
|
song_request: 唱歌请求,如 "请唱一首关于春天的歌"
|
||||||
|
output_file: 输出音频文件路径
|
||||||
|
language: 语言代码 (zh-CN/en-US)
|
||||||
|
model: 模型版本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 输出文件路径
|
||||||
|
"""
|
||||||
|
print(f"🎵 豆包唱歌中...")
|
||||||
|
print(f" 请求: {song_request}")
|
||||||
|
print(f" 模型: {model}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio_data = bytearray()
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# WebSocket连接头
|
||||||
|
headers = {
|
||||||
|
"X-Api-App-ID": self.app_id,
|
||||||
|
"X-Api-Access-Key": self.access_token,
|
||||||
|
"X-Api-Resource-Id": self.resource_id,
|
||||||
|
"X-Api-App-Key": self.app_key,
|
||||||
|
"X-Api-Connect-Id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
|
||||||
|
async with websockets.connect(self.ws_url, additional_headers=headers) as websocket:
|
||||||
|
print("✅ WebSocket连接成功")
|
||||||
|
|
||||||
|
# 1. StartConnection (event_id=1, 无需session_id)
|
||||||
|
await websocket.send(self._build_message(1))
|
||||||
|
response = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||||
|
resp = self._parse_response(response)
|
||||||
|
if resp.get("event_id") == 50:
|
||||||
|
print(f"✅ 连接已建立")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ 连接响应: {resp}")
|
||||||
|
|
||||||
|
# 2. StartSession (event_id=100, 需要session_id)
|
||||||
|
start_session_payload = {
|
||||||
|
"tts": {
|
||||||
|
"audio_config": {
|
||||||
|
"channel": 1,
|
||||||
|
"format": "pcm",
|
||||||
|
"sample_rate": 24000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"extra": {
|
||||||
|
"enable_music": True,
|
||||||
|
"input_mod": "text",
|
||||||
|
"model": model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await websocket.send(self._build_message(100, start_session_payload, session_id))
|
||||||
|
response = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||||
|
resp = self._parse_response(response)
|
||||||
|
if resp.get("event_id") == 150:
|
||||||
|
print(f"✅ 会话已建立")
|
||||||
|
elif resp.get("payload", {}).get("error"):
|
||||||
|
print(f"❌ 会话错误: {resp['payload']['error']}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
print(f"📋 会话响应: {resp}")
|
||||||
|
|
||||||
|
# 3. SayHello/ChatTextQuery (event_id=300, 需要session_id)
|
||||||
|
chat_payload = {"content": song_request}
|
||||||
|
await websocket.send(self._build_message(300, chat_payload, session_id))
|
||||||
|
print(f"📤 已发送唱歌请求")
|
||||||
|
|
||||||
|
# 4. 接收音频流(使用超时检测结束)
|
||||||
|
print("\n📋 接收音频流...")
|
||||||
|
tts_started = False
|
||||||
|
recv_timeout = 5 # 5秒无数据则认为结束
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message = await asyncio.wait_for(websocket.recv(), timeout=recv_timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
except websockets.exceptions.ConnectionClosed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if isinstance(message, bytes) and len(message) >= 4:
|
||||||
|
resp = self._parse_response(message)
|
||||||
|
msg_type = resp.get("msg_type", 0)
|
||||||
|
flags = message[1] & 0x0F
|
||||||
|
|
||||||
|
# Audio-only response (0xB = 11)
|
||||||
|
if resp.get("is_audio") and resp.get("payload_bytes"):
|
||||||
|
audio_data.extend(resp["payload_bytes"])
|
||||||
|
if not tts_started:
|
||||||
|
print(f" 接收音频中...", end="", flush=True)
|
||||||
|
tts_started = True
|
||||||
|
else:
|
||||||
|
print(".", end="", flush=True)
|
||||||
|
|
||||||
|
# NEG_SEQUENCE flag = last packet
|
||||||
|
if flags & 0x02:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Server error (0xF = 15)
|
||||||
|
elif msg_type == 0x0F:
|
||||||
|
error = resp.get("payload", {}).get("error", "unknown")
|
||||||
|
print(f"\n❌ 服务器错误: {error}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Full server response (0x9) - session finished
|
||||||
|
elif msg_type == 0x09:
|
||||||
|
event_id = resp.get("event_id", 0)
|
||||||
|
if event_id in {152, 52}:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 5. 保存音频文件
|
||||||
|
if audio_data:
|
||||||
|
# Save as PCM, convert extension if needed
|
||||||
|
actual_output = output_file
|
||||||
|
if output_file.endswith('.mp3'):
|
||||||
|
actual_output = output_file.replace('.mp3', '.pcm')
|
||||||
|
|
||||||
|
with open(actual_output, "wb") as f:
|
||||||
|
f.write(audio_data)
|
||||||
|
|
||||||
|
file_size = len(audio_data) / 1024
|
||||||
|
print(f"\n\n✅ 唱歌完成!")
|
||||||
|
print(f" 输出: {actual_output} ({file_size:.1f} KB)")
|
||||||
|
print(f" 格式: PCM (24000Hz, 单声道)")
|
||||||
|
return actual_output
|
||||||
|
else:
|
||||||
|
print("\n⚠️ 未收到音频数据,请检查:")
|
||||||
|
print(" 1. 凭证是否正确")
|
||||||
|
print(" 2. 端到端实时语音大模型是否已开通")
|
||||||
|
print(" 3. 网络连接是否正常")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except websockets.exceptions.WebSocketException as e:
|
||||||
|
raise Exception(f"WebSocket连接错误: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"唱歌调用失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""命令行工具"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="豆包唱歌工具")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="选择功能")
|
||||||
|
|
||||||
|
# 唱歌命令
|
||||||
|
sing_parser = subparsers.add_parser("sing", help="让豆包唱歌")
|
||||||
|
sing_parser.add_argument("request", help="唱歌请求,如 '请唱一首关于春天的歌'")
|
||||||
|
sing_parser.add_argument(
|
||||||
|
"-o", "--output", default="singing_output.mp3", help="输出音频文件(默认: singing_output.mp3)"
|
||||||
|
)
|
||||||
|
sing_parser.add_argument(
|
||||||
|
"-l", "--language", default="zh-CN", help="语言代码(默认: zh-CN)"
|
||||||
|
)
|
||||||
|
sing_parser.add_argument(
|
||||||
|
"-m", "--model", default="1.2.1.0", help="模型版本(默认: 1.2.1.0=O2.0版本)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
singing = DoubaoSinging()
|
||||||
|
|
||||||
|
if args.command == "sing":
|
||||||
|
asyncio.run(singing.sing(args.request, args.output, args.language, args.model))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 错误: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
171
plugins/doubao-voice-plugin/scripts/voice_converter.py
Executable file
171
plugins/doubao-voice-plugin/scripts/voice_converter.py
Executable file
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
豆包语音转换工具
|
||||||
|
支持:文字转语音 (TTS)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class DoubaoVoiceConverter:
|
||||||
|
"""豆包语音转换工具类"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 从环境变量读取配置
|
||||||
|
self.app_id = os.environ.get("DOUBAO_APP_ID")
|
||||||
|
self.access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||||
|
|
||||||
|
if not self.app_id or not self.access_token:
|
||||||
|
raise ValueError(
|
||||||
|
"请先设置环境变量:\n"
|
||||||
|
"export DOUBAO_APP_ID='your_app_id'\n"
|
||||||
|
"export DOUBAO_ACCESS_TOKEN='your_access_token'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# API版本选择: V1 (默认, 支持基础音色) 或 V3 (豆包2.0, 需额外配置)
|
||||||
|
self.use_v3 = os.environ.get("DOUBAO_USE_V3", "false").lower() == "true"
|
||||||
|
|
||||||
|
if self.use_v3:
|
||||||
|
self.tts_url = "https://openspeech.bytedance.com/api/v3/tts/unidirectional"
|
||||||
|
self.resource_id = os.environ.get("DOUBAO_RESOURCE_ID", "volc.bigmodel.tts")
|
||||||
|
else:
|
||||||
|
# V1 API - 稳定可用,支持基础音色
|
||||||
|
self.tts_url = "https://openspeech.bytedance.com/api/v1/tts"
|
||||||
|
|
||||||
|
def text_to_speech(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
output_file: str = "output.mp3",
|
||||||
|
voice_type: str = "BV700_V2_streaming"
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
文字转语音 (TTS)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 要转换的文字
|
||||||
|
output_file: 输出音频文件路径
|
||||||
|
voice_type: 音色类型
|
||||||
|
- BV700_V2_streaming: 通用女声(推荐)
|
||||||
|
- BV701_V2_streaming: 通用男声
|
||||||
|
- BV406_streaming: 温柔女声
|
||||||
|
- BV158_streaming: 活泼女声
|
||||||
|
- BV115_streaming: 磁性男声
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 输出文件路径
|
||||||
|
"""
|
||||||
|
print(f"📝 文字转语音中...")
|
||||||
|
print(f" 文字: {text[:50]}{'...' if len(text) > 50 else ''}")
|
||||||
|
print(f" 音色: {voice_type}")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer;{self.access_token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# V3 API需要Resource-Id (如果启用)
|
||||||
|
if self.use_v3:
|
||||||
|
headers["Resource-Id"] = self.resource_id
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"app": {
|
||||||
|
"appid": self.app_id,
|
||||||
|
"token": self.access_token,
|
||||||
|
"cluster": "volcano_tts"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"uid": "user_001"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"voice_type": voice_type,
|
||||||
|
"encoding": "mp3",
|
||||||
|
"speed_ratio": 1.0,
|
||||||
|
"volume_ratio": 1.0,
|
||||||
|
"pitch_ratio": 1.0
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"reqid": f"tts_{os.urandom(8).hex()}",
|
||||||
|
"text": text,
|
||||||
|
"text_type": "plain",
|
||||||
|
"operation": "query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(self.tts_url, headers=headers, json=payload, timeout=30)
|
||||||
|
|
||||||
|
# 打印响应头信息
|
||||||
|
print(f"\n📋 响应信息:")
|
||||||
|
print(f" HTTP状态码: {response.status_code}")
|
||||||
|
if 'X-Tt-Logid' in response.headers:
|
||||||
|
print(f" RequestId: {response.headers['X-Tt-Logid']}")
|
||||||
|
if 'X-Request-Id' in response.headers:
|
||||||
|
print(f" X-Request-Id: {response.headers['X-Request-Id']}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# 打印完整响应
|
||||||
|
print(f"\n📄 完整响应:")
|
||||||
|
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
print()
|
||||||
|
|
||||||
|
if data.get("code") == 3000:
|
||||||
|
# 成功:解码并保存音频
|
||||||
|
audio_data = base64.b64decode(data["data"])
|
||||||
|
with open(output_file, "wb") as f:
|
||||||
|
f.write(audio_data)
|
||||||
|
|
||||||
|
file_size = len(audio_data) / 1024 # KB
|
||||||
|
print(f"✅ 语音合成成功!")
|
||||||
|
print(f" 输出: {output_file} ({file_size:.1f} KB)")
|
||||||
|
return output_file
|
||||||
|
else:
|
||||||
|
error_msg = data.get("message", "未知错误")
|
||||||
|
reqid = data.get("reqid", "未知")
|
||||||
|
raise Exception(f"TTS 失败\n 错误码: {data.get('code')}\n 错误信息: {error_msg}\n RequestId: {reqid}")
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
raise Exception("请求超时,请检查网络连接")
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"TTS 调用失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""命令行工具"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="豆包语音转换工具")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="选择功能")
|
||||||
|
|
||||||
|
# TTS 命令
|
||||||
|
tts_parser = subparsers.add_parser("tts", help="文字转语音")
|
||||||
|
tts_parser.add_argument("text", help="要转换的文字")
|
||||||
|
tts_parser.add_argument("-o", "--output", default="output.mp3", help="输出音频文件(默认: output.mp3)")
|
||||||
|
tts_parser.add_argument("-v", "--voice", default="BV700_V2_streaming",
|
||||||
|
help="音色类型(默认: BV700_V2_streaming 通用女声)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
converter = DoubaoVoiceConverter()
|
||||||
|
|
||||||
|
if args.command == "tts":
|
||||||
|
converter.text_to_speech(args.text, args.output, args.voice)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 错误: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
508
plugins/doubao-voice-plugin/skills/SKILL.md
Normal file
508
plugins/doubao-voice-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
---
|
||||||
|
name: doubao-voice
|
||||||
|
description: 豆包语音API调用。支持语音合成(TTS)和唱歌。当用户提到语音合成、文字转语音、唱歌、豆包语音相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 豆包语音API技能
|
||||||
|
|
||||||
|
调用火山引擎豆包语音API,实现文字转语音(TTS)和唱歌功能。
|
||||||
|
|
||||||
|
## 核心功能 ⭐
|
||||||
|
|
||||||
|
### 1. 文字转语音 (TTS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 配置环境变量
|
||||||
|
export DOUBAO_APP_ID="your_app_id"
|
||||||
|
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||||
|
|
||||||
|
# 2. 文字转语音
|
||||||
|
python scripts/voice_converter.py tts "你好世界"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 唱歌 🎵
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 让豆包唱歌
|
||||||
|
python scripts/singing.py sing "请唱一首关于春天的歌"
|
||||||
|
|
||||||
|
# 交互式唱歌模式
|
||||||
|
python scripts/singing.py interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
| 模块 | 功能 | 推荐模型 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **语音合成 (TTS)** | 文字转语音、多种音色 | 豆包语音合成模型2.0 |
|
||||||
|
| **唱歌** | 实时语音交互、唱歌、角色扮演 | 豆包端到端实时语音大模型 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
### 1. 获取火山引擎豆包语音凭证
|
||||||
|
|
||||||
|
1. 访问 [火山引擎控制台](https://console.volcengine.com/)
|
||||||
|
2. 开通「豆包语音」服务
|
||||||
|
3. 创建应用获取 `App ID` 和 `Access Token`
|
||||||
|
4. 开通所需服务:
|
||||||
|
- 「语音合成」权限:大模型语音合成
|
||||||
|
|
||||||
|
### 2. 环境变量配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ~/.zshrc 或 ~/.bashrc
|
||||||
|
export DOUBAO_APP_ID="your_app_id"
|
||||||
|
export DOUBAO_ACCESS_TOKEN="your_access_token"
|
||||||
|
export DOUBAO_CLUSTER="volcano_tts" # TTS服务集群
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Python 依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推荐使用 uv
|
||||||
|
uv pip install requests websocket-client
|
||||||
|
|
||||||
|
# 或使用 pip
|
||||||
|
pip install requests websocket-client
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 基础
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
TTS API: https://openspeech.bytedance.com/api/v1/tts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 认证方式
|
||||||
|
|
||||||
|
使用 Access Token 进行认证,在请求头中添加:
|
||||||
|
```
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、语音合成 (TTS)
|
||||||
|
|
||||||
|
### 1.1 基础语音合成
|
||||||
|
|
||||||
|
将文本转换为语音文件。
|
||||||
|
|
||||||
|
**自然语言示例**:
|
||||||
|
- "把这段文字转成语音"
|
||||||
|
- "用豆包合成语音"
|
||||||
|
- "生成语音:你好,欢迎使用豆包语音"
|
||||||
|
|
||||||
|
**Python 实现**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
|
def text_to_speech(text: str, voice_type: str = "BV700_V2_streaming", output_file: str = "output.mp3"):
|
||||||
|
"""
|
||||||
|
文字转语音
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 要合成的文本
|
||||||
|
voice_type: 音色类型 (默认: BV700_V2_streaming)
|
||||||
|
output_file: 输出音频文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
音频文件路径
|
||||||
|
"""
|
||||||
|
app_id = os.environ.get("DOUBAO_APP_ID")
|
||||||
|
access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||||
|
cluster = os.environ.get("DOUBAO_CLUSTER", "volcano_tts")
|
||||||
|
|
||||||
|
url = "https://openspeech.bytedance.com/api/v1/tts"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"app": {
|
||||||
|
"appid": app_id,
|
||||||
|
"token": access_token,
|
||||||
|
"cluster": cluster
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"uid": "user123"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"voice_type": voice_type,
|
||||||
|
"encoding": "mp3",
|
||||||
|
"speed_ratio": 1.0,
|
||||||
|
"volume_ratio": 1.0,
|
||||||
|
"pitch_ratio": 1.0
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"reqid": "req_" + os.urandom(8).hex(),
|
||||||
|
"text": text,
|
||||||
|
"text_type": "plain",
|
||||||
|
"operation": "query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 3000:
|
||||||
|
# 解码音频数据
|
||||||
|
audio_data = base64.b64decode(data["data"])
|
||||||
|
with open(output_file, "wb") as f:
|
||||||
|
f.write(audio_data)
|
||||||
|
return output_file
|
||||||
|
else:
|
||||||
|
raise Exception(f"TTS 失败: {data}")
|
||||||
|
|
||||||
|
# 使用示例
|
||||||
|
audio_file = text_to_speech("你好,我是豆包语音助手")
|
||||||
|
print(f"语音已生成: {audio_file}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 流式语音合成
|
||||||
|
|
||||||
|
适用于长文本,边生成边播放。
|
||||||
|
|
||||||
|
```python
|
||||||
|
import websocket
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
def stream_tts(text: str, voice_type: str = "BV700_V2_streaming"):
|
||||||
|
"""
|
||||||
|
流式语音合成
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 要合成的文本
|
||||||
|
voice_type: 音色类型
|
||||||
|
"""
|
||||||
|
app_id = os.environ.get("DOUBAO_APP_ID")
|
||||||
|
access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||||
|
|
||||||
|
ws_url = f"wss://openspeech.bytedance.com/api/v1/tts/ws?appid={app_id}&token={access_token}"
|
||||||
|
|
||||||
|
def on_message(ws, message):
|
||||||
|
data = json.loads(message)
|
||||||
|
if "audio" in data:
|
||||||
|
# 处理音频数据
|
||||||
|
audio_chunk = base64.b64decode(data["audio"])
|
||||||
|
# 播放或保存音频片段
|
||||||
|
print(f"收到音频片段: {len(audio_chunk)} 字节")
|
||||||
|
|
||||||
|
def on_open(ws):
|
||||||
|
payload = {
|
||||||
|
"app": {
|
||||||
|
"appid": app_id,
|
||||||
|
"token": access_token,
|
||||||
|
"cluster": "volcano_tts"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"uid": "user123"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"voice_type": voice_type,
|
||||||
|
"encoding": "mp3"
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"reqid": "stream_" + os.urandom(8).hex(),
|
||||||
|
"text": text,
|
||||||
|
"text_type": "plain",
|
||||||
|
"operation": "submit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.send(json.dumps(payload))
|
||||||
|
|
||||||
|
ws = websocket.WebSocketApp(
|
||||||
|
ws_url,
|
||||||
|
on_message=on_message,
|
||||||
|
on_open=on_open
|
||||||
|
)
|
||||||
|
ws.run_forever()
|
||||||
|
|
||||||
|
# 使用示例
|
||||||
|
stream_tts("这是一段很长的文本,使用流式合成可以边生成边播放...")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 音色选择
|
||||||
|
|
||||||
|
豆包语音提供多种音色:
|
||||||
|
|
||||||
|
| 音色代码 | 描述 | 场景 |
|
||||||
|
|---------|------|------|
|
||||||
|
| BV700_V2_streaming | 通用女声 | 通用场景 |
|
||||||
|
| BV701_V2_streaming | 通用男声 | 通用场景 |
|
||||||
|
| BV406_streaming | 温柔女声 | 客服、助手 |
|
||||||
|
| BV158_streaming | 活泼女声 | 教育、娱乐 |
|
||||||
|
| BV115_streaming | 磁性男声 | 新闻、播音 |
|
||||||
|
|
||||||
|
**查询可用音色**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="${DOUBAO_ACCESS_TOKEN}"
|
||||||
|
APP_ID="${DOUBAO_APP_ID}"
|
||||||
|
|
||||||
|
curl -s "https://openspeech.bytedance.com/api/v1/tts/voices?appid=$APP_ID" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工具类
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class DoubaoVoice:
|
||||||
|
"""豆包语音API工具类"""
|
||||||
|
|
||||||
|
BASE_URL = "https://openspeech.bytedance.com/api/v1"
|
||||||
|
|
||||||
|
def __init__(self, app_id: str = None, access_token: str = None):
|
||||||
|
self.app_id = app_id or os.environ.get("DOUBAO_APP_ID")
|
||||||
|
self.access_token = access_token or os.environ.get("DOUBAO_ACCESS_TOKEN")
|
||||||
|
self.cluster_tts = os.environ.get("DOUBAO_CLUSTER", "volcano_tts")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self):
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def text_to_speech(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
voice_type: str = "BV700_V2_streaming",
|
||||||
|
output_file: str = "output.mp3"
|
||||||
|
) -> str:
|
||||||
|
"""文字转语音"""
|
||||||
|
url = f"{self.BASE_URL}/tts"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"app": {
|
||||||
|
"appid": self.app_id,
|
||||||
|
"token": self.access_token,
|
||||||
|
"cluster": self.cluster_tts
|
||||||
|
},
|
||||||
|
"user": {"uid": "user123"},
|
||||||
|
"audio": {
|
||||||
|
"voice_type": voice_type,
|
||||||
|
"encoding": "mp3",
|
||||||
|
"speed_ratio": 1.0,
|
||||||
|
"volume_ratio": 1.0,
|
||||||
|
"pitch_ratio": 1.0
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"reqid": "req_" + os.urandom(8).hex(),
|
||||||
|
"text": text,
|
||||||
|
"text_type": "plain",
|
||||||
|
"operation": "query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self.headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 3000:
|
||||||
|
audio_data = base64.b64decode(data["data"])
|
||||||
|
with open(output_file, "wb") as f:
|
||||||
|
f.write(audio_data)
|
||||||
|
return output_file
|
||||||
|
else:
|
||||||
|
raise Exception(f"TTS 失败: {data}")
|
||||||
|
|
||||||
|
def list_voices(self) -> list:
|
||||||
|
"""获取可用音色列表"""
|
||||||
|
url = f"{self.BASE_URL}/tts/voices"
|
||||||
|
params = {"appid": self.app_id}
|
||||||
|
|
||||||
|
response = requests.get(url, headers=self.headers, params=params)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["voices"]
|
||||||
|
else:
|
||||||
|
raise Exception(f"获取音色列表失败: {data}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 使用示例 ====================
|
||||||
|
if __name__ == "__main__":
|
||||||
|
voice = DoubaoVoice()
|
||||||
|
|
||||||
|
# 示例1: 文字转语音
|
||||||
|
audio_file = voice.text_to_speech("你好,我是豆包语音助手")
|
||||||
|
print(f"语音已生成: {audio_file}")
|
||||||
|
|
||||||
|
# 示例2: 查看可用音色
|
||||||
|
voices = voice.list_voices()
|
||||||
|
for v in voices[:5]:
|
||||||
|
print(f"{v['voice_type']}: {v['description']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、唱歌 (豆包端到端实时语音大模型)
|
||||||
|
|
||||||
|
### 2.1 基础唱歌
|
||||||
|
|
||||||
|
让豆包唱歌,支持任何歌曲主题。
|
||||||
|
|
||||||
|
**自然语言示例**:
|
||||||
|
- "请唱一首关于春天的歌"
|
||||||
|
- "唱一个温柔的摇篮曲"
|
||||||
|
- "来一首欢快的儿歌"
|
||||||
|
|
||||||
|
**Python 实现**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from scripts.singing import DoubaoSinging
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
singing = DoubaoSinging()
|
||||||
|
|
||||||
|
# 让豆包唱歌
|
||||||
|
audio_file = await singing.sing(
|
||||||
|
"请唱一首关于春天的歌",
|
||||||
|
output_file="spring_song.mp3",
|
||||||
|
language="zh-CN"
|
||||||
|
)
|
||||||
|
print(f"唱歌完成: {audio_file}")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 交互式唱歌
|
||||||
|
|
||||||
|
与豆包进行实时对话,可以要求她唱歌、讲故事等。
|
||||||
|
|
||||||
|
**Python 实现**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from scripts.singing import DoubaoSinging
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
singing = DoubaoSinging()
|
||||||
|
|
||||||
|
# 启动交互式模式
|
||||||
|
await singing.interactive_singing(language="zh-CN")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
**交互示例**:
|
||||||
|
```
|
||||||
|
你: 请唱一首情歌
|
||||||
|
豆包: [生成音频] 我会为你唱一首温柔的情歌...
|
||||||
|
|
||||||
|
你: 能加点方言吗?
|
||||||
|
豆包: [用方言重新唱歌]
|
||||||
|
|
||||||
|
你: quit
|
||||||
|
再见!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自然语言操作示例
|
||||||
|
|
||||||
|
### TTS 操作
|
||||||
|
|
||||||
|
| 用户说 | 执行操作 |
|
||||||
|
|--------|----------|
|
||||||
|
| "把这段话转成语音:你好世界" | 调用 TTS API 生成语音 |
|
||||||
|
| "用温柔女声合成语音" | 使用 BV406_streaming 音色 |
|
||||||
|
| "生成一段播音腔的新闻语音" | 使用磁性男声音色 |
|
||||||
|
|
||||||
|
### 唱歌操作
|
||||||
|
|
||||||
|
| 用户说 | 执行操作 |
|
||||||
|
|--------|----------|
|
||||||
|
| "请唱一首关于春天的歌" | 调用端到端实时语音大模型生成唱歌音频 |
|
||||||
|
| "唱一首摇篮曲" | 生成温柔的摇篮曲 |
|
||||||
|
| "唱歌的同时讲个故事" | 交互式对话中唱歌并讲故事 |
|
||||||
|
| "开启交互式唱歌模式" | 启动实时语音交互 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 计费说明
|
||||||
|
|
||||||
|
### TTS 计费
|
||||||
|
|
||||||
|
- **并发版**: 2000元/并发/月(纯并发计费,不收取字符调用费用)
|
||||||
|
- **按量付费**: 按合成字符数计费
|
||||||
|
|
||||||
|
### 免费试用
|
||||||
|
|
||||||
|
新用户开通服务后可获得一定免费额度,具体额度以控制台显示为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **音频格式**: TTS 支持 mp3/wav/pcm
|
||||||
|
2. **文本长度**: TTS 单次请求最长支持 5000 字符
|
||||||
|
3. **并发限制**: 注意 API 调用频率和并发数限制
|
||||||
|
4. **Token 安全**: Access Token 存储在环境变量中,不要硬编码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
def safe_tts(text: str):
|
||||||
|
"""带错误处理的 TTS"""
|
||||||
|
try:
|
||||||
|
voice = DoubaoVoice()
|
||||||
|
return voice.text_to_speech(text)
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e):
|
||||||
|
print("认证失败,请检查 Access Token")
|
||||||
|
elif "429" in str(e):
|
||||||
|
print("请求过于频繁,请稍后重试")
|
||||||
|
else:
|
||||||
|
print(f"合成失败: {e}")
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见场景
|
||||||
|
|
||||||
|
### 场景 1: 生成多语言语音
|
||||||
|
|
||||||
|
```python
|
||||||
|
voice = DoubaoVoice()
|
||||||
|
|
||||||
|
# 中文
|
||||||
|
voice.text_to_speech("你好", voice_type="BV700_V2_streaming", output_file="zh.mp3")
|
||||||
|
|
||||||
|
# 英文
|
||||||
|
voice.text_to_speech("Hello", voice_type="EN_001", output_file="en.mp3")
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考资源
|
||||||
|
|
||||||
|
- [火山引擎豆包语音文档](https://www.volcengine.com/docs/6561/1359369)
|
||||||
|
- [豆包语音控制台](https://console.volcengine.com/speech/app)
|
||||||
|
- [API 接口文档](https://www.volcengine.com/docs/6561/1359370)
|
||||||
|
- [计费说明](https://www.volcengine.com/docs/6561/1359370)
|
||||||
8
plugins/enjoysa-deploy-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/enjoysa-deploy-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "enjoysa-deploy-plugin",
|
||||||
|
"description": "EnjoySA 项目部署到新加坡服务器",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
113
plugins/enjoysa-deploy-plugin/skills/SKILL.md
Normal file
113
plugins/enjoysa-deploy-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# EnjoySA 部署技能
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
|
||||||
|
当用户提到以下内容时自动激活:
|
||||||
|
- enjoysa 部署
|
||||||
|
- 部署到新加坡
|
||||||
|
- deploy enjoysa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 本地路径 | `/Users/donglinlai/coding/qiudl/enjoysa` |
|
||||||
|
| Git 仓库 | `https://gitea.pipexerp.com/qiudl/enjoysa.git` |
|
||||||
|
| 主分支 | main |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 服务器信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 服务器 | singapore (43.134.28.147) |
|
||||||
|
| 用户 | ubuntu |
|
||||||
|
| SSH 密钥 | ~/.ssh/singpore.pem |
|
||||||
|
| 部署路径 | /opt/enjoysa |
|
||||||
|
| 访问地址 | http://43.134.28.147:6066 |
|
||||||
|
| Web 服务 | Nginx (端口 6066) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速部署
|
||||||
|
|
||||||
|
执行部署脚本:
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 手动部署步骤
|
||||||
|
|
||||||
|
### 1. 构建
|
||||||
|
```bash
|
||||||
|
cd web && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 上传
|
||||||
|
```bash
|
||||||
|
scp -r web/dist/* singapore:/opt/enjoysa/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 验证
|
||||||
|
```bash
|
||||||
|
ssh singapore "curl -s -o /dev/null -w '%{http_code}' http://localhost:6066/"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用运维命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看 Nginx 状态
|
||||||
|
ssh singapore "sudo systemctl status nginx"
|
||||||
|
|
||||||
|
# 重载 Nginx 配置
|
||||||
|
ssh singapore "sudo nginx -t && sudo systemctl reload nginx"
|
||||||
|
|
||||||
|
# 查看部署目录
|
||||||
|
ssh singapore "ls -la /opt/enjoysa/"
|
||||||
|
|
||||||
|
# 查看 Nginx 访问日志
|
||||||
|
ssh singapore "sudo tail -f /var/log/nginx/access.log"
|
||||||
|
|
||||||
|
# 查看错误日志
|
||||||
|
ssh singapore "sudo tail -f /var/log/nginx/error.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nginx 配置
|
||||||
|
|
||||||
|
配置文件: `/etc/nginx/sites-available/enjoysa`
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 6066;
|
||||||
|
listen [::]:6066;
|
||||||
|
server_name _;
|
||||||
|
root /opt/enjoysa;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 腾讯云安全组需开放 6066 端口
|
||||||
|
2. SPA 应用使用 `try_files` 支持前端路由
|
||||||
|
3. 静态资源设置长期缓存
|
||||||
8
plugins/enjoysa-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/enjoysa-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "enjoysa-plugin",
|
||||||
|
"description": "Plugin for enjoysa",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
210
plugins/enjoysa-plugin/skills/SKILL.md
Normal file
210
plugins/enjoysa-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
---
|
||||||
|
name: enjoysa
|
||||||
|
description: EnjoySA 畅游南澳旅游平台开发。用于前端页面开发、组件开发、i18n国际化。当用户提到 enjoysa、畅游南澳、旅游平台、供应商后台相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# EnjoySA 开发技能
|
||||||
|
|
||||||
|
畅游南澳旅游平台,面向 C 端游客和 B 端供应商的在线旅游服务系统。
|
||||||
|
|
||||||
|
## 项目信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 本地路径 | `/Users/donglinlai/coding/qiudl/enjoysa` |
|
||||||
|
| Git 仓库 | `https://gitea.pipexerp.com/qiudl/enjoysa.git` |
|
||||||
|
| 主分支 | main |
|
||||||
|
| 技术栈 | Vite + React 18 + TypeScript + CSS Modules |
|
||||||
|
| 部署服务器 | singapore (43.134.28.147:6066) |
|
||||||
|
| 访问地址 | http://43.134.28.147:6066 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
enjoysa/
|
||||||
|
├── web/ # React 前端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # 通用组件
|
||||||
|
│ │ │ ├── Common/ # Header, Footer, LanguageSwitcher
|
||||||
|
│ │ │ ├── Auth/ # LoginForm, RegisterForm
|
||||||
|
│ │ │ └── Supplier/ # SupplierLoginForm, SupplierRegisterForm
|
||||||
|
│ │ ├── pages/ # 页面组件
|
||||||
|
│ │ │ ├── LoginPage/ # C端用户登录
|
||||||
|
│ │ │ ├── SupplierLoginPage/# B端供应商登录
|
||||||
|
│ │ │ ├── AdminLoginPage/ # 平台管理员登录
|
||||||
|
│ │ │ ├── TermsPage/ # 服务条款
|
||||||
|
│ │ │ ├── PrivacyPage/ # 隐私政策
|
||||||
|
│ │ │ └── Supplier/ # 供应商后台模块
|
||||||
|
│ │ ├── i18n/ # 国际化
|
||||||
|
│ │ │ └── locales/ # 翻译文件
|
||||||
|
│ │ │ ├── zh-CN.json
|
||||||
|
│ │ │ └── en-US.json
|
||||||
|
│ │ └── types/ # 类型定义
|
||||||
|
│ ├── scripts/ # 部署脚本
|
||||||
|
│ └── dist/ # 构建产物
|
||||||
|
├── backend/ # Go 后端 (规划中)
|
||||||
|
└── docs/ # 文档
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 页面路由
|
||||||
|
|
||||||
|
### C 端 (游客)
|
||||||
|
|
||||||
|
| 路由 | 页面 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/` | HomePage | 首页 |
|
||||||
|
| `/login` | LoginPage | 用户登录/注册 |
|
||||||
|
| `/terms` | TermsPage | 服务条款 |
|
||||||
|
| `/privacy` | PrivacyPage | 隐私政策 |
|
||||||
|
|
||||||
|
### B 端 (供应商)
|
||||||
|
|
||||||
|
| 路由 | 页面 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/supplier/login` | SupplierLoginPage | 供应商登录 |
|
||||||
|
| `/supplier/register` | SupplierRegisterPage | 供应商入驻申请 |
|
||||||
|
| `/supplier` | SupplierDashboard | 供应商仪表盘 |
|
||||||
|
| `/supplier/products` | SupplierProducts | 产品管理 |
|
||||||
|
| `/supplier/orders` | SupplierOrders | 订单处理 |
|
||||||
|
| `/supplier/finance` | SupplierFinance | 财务结算 |
|
||||||
|
|
||||||
|
### 管理端
|
||||||
|
|
||||||
|
| 路由 | 页面 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/admin/login` | AdminLoginPage | 管理员登录 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n 国际化
|
||||||
|
|
||||||
|
### 翻译文件位置
|
||||||
|
|
||||||
|
```
|
||||||
|
web/src/i18n/locales/
|
||||||
|
├── zh-CN.json # 简体中文
|
||||||
|
└── en-US.json # 英文
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命名空间规范
|
||||||
|
|
||||||
|
| 命名空间 | 用途 | 示例 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `brand.*` | 品牌信息 | brand.name, brand.tagline |
|
||||||
|
| `auth.*` | 登录注册 | auth.loginTitle, auth.emailPlaceholder |
|
||||||
|
| `terms.*` | 服务条款 | terms.title, terms.sections.* |
|
||||||
|
| `privacy.*` | 隐私政策 | privacy.title, privacy.sections.* |
|
||||||
|
| `supplier.*` | 供应商模块 | supplier.login.*, supplier.dashboard.* |
|
||||||
|
| `admin.*` | 管理员模块 | admin.login.*, admin.dashboard.* |
|
||||||
|
| `common.*` | 通用文本 | common.submit, common.cancel |
|
||||||
|
| `footer.*` | 页脚 | footer.supplierEntry, footer.copyright |
|
||||||
|
|
||||||
|
### 添加新翻译步骤
|
||||||
|
|
||||||
|
1. 在 `zh-CN.json` 和 `en-US.json` 中同时添加新 key
|
||||||
|
2. 使用 `useTranslation()` hook 获取 `t` 函数
|
||||||
|
3. 使用 `t('namespace.key')` 获取翻译文本
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const MyComponent = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <h1>{t('brand.name')}</h1>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- **禁止硬编码中文/英文**,所有用户可见文本必须使用 i18n
|
||||||
|
- 长文本内容(如条款、政策)使用 `\n` 分段
|
||||||
|
- JSON 中避免使用中文引号 `""` `''`,使用 `「」` 或英文引号
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 组件规范
|
||||||
|
|
||||||
|
### Common 组件
|
||||||
|
|
||||||
|
| 组件 | 用途 | 文件 |
|
||||||
|
|------|------|------|
|
||||||
|
| Header | 顶部导航栏 | `components/Common/Header/` |
|
||||||
|
| Footer | 页脚(含供应商入口) | `components/Common/Footer/` |
|
||||||
|
| LanguageSwitcher | 语言切换器 | `components/Common/LanguageSwitcher/` |
|
||||||
|
|
||||||
|
### Auth 组件
|
||||||
|
|
||||||
|
| 组件 | 用途 | 文件 |
|
||||||
|
|------|------|------|
|
||||||
|
| LoginForm | C端登录表单 | `components/Auth/LoginForm/` |
|
||||||
|
| RegisterForm | C端注册表单 | `components/Auth/RegisterForm/` |
|
||||||
|
|
||||||
|
### Supplier 组件
|
||||||
|
|
||||||
|
| 组件 | 用途 | 文件 |
|
||||||
|
|------|------|------|
|
||||||
|
| SupplierLoginForm | 供应商登录表单 | `components/Supplier/SupplierLoginForm/` |
|
||||||
|
| SupplierRegisterForm | 供应商入驻申请表单 | `components/Supplier/SupplierRegisterForm/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 启动前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/donglinlai/coding/qiudl/enjoysa/web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
或手动部署:
|
||||||
|
```bash
|
||||||
|
scp -r web/dist/* singapore:/opt/enjoysa/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 供应商类型
|
||||||
|
|
||||||
|
系统支持以下供应商类型:
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| travel_agency | 旅行社 |
|
||||||
|
| hotel | 酒店 |
|
||||||
|
| attraction | 景区 |
|
||||||
|
| restaurant | 餐厅 |
|
||||||
|
| car_rental | 租车公司 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
- `enjoysa-deploy` - 部署到新加坡服务器
|
||||||
|
- `frontend-design` - 前端界面设计
|
||||||
|
- `dev-coding` - 软件编码开发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1.0.0 | 2026-01-31 | 初始版本,包含项目结构、页面路由、i18n规范 |
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "executing-plans-plugin",
|
||||||
|
"description": "Plugin for executing-plans",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
96
plugins/executing-plans-plugin/skills/SKILL.md
Normal file
96
plugins/executing-plans-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: executing-plans
|
||||||
|
description: Use when you have a written implementation plan to execute in a separate session with review checkpoints
|
||||||
|
---
|
||||||
|
|
||||||
|
# Executing Plans
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Load plan, review critically, create branch, execute tasks in batches, report for review between batches.
|
||||||
|
|
||||||
|
**Core principle:** Batch execution with checkpoints for architect review.
|
||||||
|
|
||||||
|
**Announce at start:** "I'm using the executing-plans skill to implement this plan."
|
||||||
|
|
||||||
|
## The Process
|
||||||
|
|
||||||
|
### Step 1: Load and Review Plan
|
||||||
|
1. Read plan file
|
||||||
|
2. Review critically - identify any questions or concerns about the plan
|
||||||
|
3. If concerns: Raise them with your human partner before starting
|
||||||
|
4. If no concerns: Proceed to branch setup
|
||||||
|
|
||||||
|
### Step 2: Setup Branch
|
||||||
|
**Before any implementation, ensure proper branch isolation.**
|
||||||
|
|
||||||
|
1. Check if already on a feature branch for this task
|
||||||
|
2. If not, use `/pr start` to create one:
|
||||||
|
```bash
|
||||||
|
/pr start <type> <REQ-id> <name>
|
||||||
|
# Example: /pr start feature REQ-123 user-login
|
||||||
|
```
|
||||||
|
3. If no REQ-id available, ask user or create branch manually:
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout -b <type>/<descriptive-name> origin/main
|
||||||
|
```
|
||||||
|
4. Confirm branch is ready before proceeding
|
||||||
|
|
||||||
|
**Branch types:** `feature`, `fix`, `refactor`
|
||||||
|
|
||||||
|
### Step 3: Create Tasks and Execute Batch
|
||||||
|
**Default: First 3 tasks**
|
||||||
|
|
||||||
|
1. Create TodoWrite tasks from plan
|
||||||
|
2. For each task in batch:
|
||||||
|
- Mark as in_progress
|
||||||
|
- Follow each step exactly (plan has bite-sized steps)
|
||||||
|
- Run verifications as specified
|
||||||
|
- Mark as completed
|
||||||
|
|
||||||
|
### Step 4: Report
|
||||||
|
When batch complete:
|
||||||
|
- Show what was implemented
|
||||||
|
- Show verification output
|
||||||
|
- Say: "Ready for feedback."
|
||||||
|
|
||||||
|
### Step 5: Continue
|
||||||
|
Based on feedback:
|
||||||
|
- Apply changes if needed
|
||||||
|
- Execute next batch
|
||||||
|
- Repeat until complete
|
||||||
|
|
||||||
|
### Step 6: Complete Development
|
||||||
|
|
||||||
|
After all tasks complete and verified:
|
||||||
|
- Announce: "I'm using the finishing-a-development-branch skill to complete this work."
|
||||||
|
- **REQUIRED SUB-SKILL:** Use superpowers:finishing-a-development-branch
|
||||||
|
- Follow that skill to verify tests, present options, execute choice
|
||||||
|
|
||||||
|
## When to Stop and Ask for Help
|
||||||
|
|
||||||
|
**STOP executing immediately when:**
|
||||||
|
- Hit a blocker mid-batch (missing dependency, test fails, instruction unclear)
|
||||||
|
- Plan has critical gaps preventing starting
|
||||||
|
- You don't understand an instruction
|
||||||
|
- Verification fails repeatedly
|
||||||
|
|
||||||
|
**Ask for clarification rather than guessing.**
|
||||||
|
|
||||||
|
## When to Revisit Earlier Steps
|
||||||
|
|
||||||
|
**Return to Review (Step 1) when:**
|
||||||
|
- Partner updates the plan based on your feedback
|
||||||
|
- Fundamental approach needs rethinking
|
||||||
|
|
||||||
|
**Don't force through blockers** - stop and ask.
|
||||||
|
|
||||||
|
## Remember
|
||||||
|
- Review plan critically first
|
||||||
|
- **Create feature branch before implementation**
|
||||||
|
- Follow plan steps exactly
|
||||||
|
- Don't skip verifications
|
||||||
|
- Reference skills when plan says to
|
||||||
|
- Between batches: just report and wait
|
||||||
|
- Stop when blocked, don't guess
|
||||||
8
plugins/feishu-bitable-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/feishu-bitable-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "feishu-bitable-plugin",
|
||||||
|
"description": "飞书多维表格操作。用于记录增删改查、批量操作、筛选排序、数据同步。当需要操作飞书多维表格时使用。",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
130
plugins/feishu-bitable-plugin/skills/SKILL.md
Normal file
130
plugins/feishu-bitable-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
name: feishu-bitable
|
||||||
|
description: 飞书多维表格操作。用于记录增删改查、批量操作、筛选排序、数据同步。当需要操作飞书多维表格时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 飞书多维表格 (Bitable)
|
||||||
|
|
||||||
|
## URL 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX
|
||||||
|
└── app_token └── table_id
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心操作
|
||||||
|
|
||||||
|
### 列出记录
|
||||||
|
|
||||||
|
```python
|
||||||
|
def list_records(app_token, table_id, filter_str=None, page_size=100):
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
params = {"page_size": page_size}
|
||||||
|
if filter_str:
|
||||||
|
params["filter"] = filter_str
|
||||||
|
response = requests.get(url, headers=headers, params=params)
|
||||||
|
return response.json()["data"]["items"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建记录
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_record(app_token, table_id, fields):
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
response = requests.post(url, headers=headers, json={"fields": fields})
|
||||||
|
return response.json()["data"]["record"]
|
||||||
|
|
||||||
|
# 示例
|
||||||
|
create_record("BascXXX", "tblXXX", {"任务名称": "完成开发", "状态": "进行中"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 批量创建 (最多 500 条)
|
||||||
|
|
||||||
|
```python
|
||||||
|
url = f".../records/batch_create"
|
||||||
|
requests.post(url, headers=headers, json={"records": [{"fields": {...}}, ...]})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新记录
|
||||||
|
|
||||||
|
```python
|
||||||
|
def update_record(app_token, table_id, record_id, fields):
|
||||||
|
url = f".../records/{record_id}"
|
||||||
|
response = requests.put(url, headers=headers, json={"fields": fields})
|
||||||
|
return response.json()["data"]["record"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除记录
|
||||||
|
|
||||||
|
```python
|
||||||
|
def delete_record(app_token, table_id, record_id):
|
||||||
|
url = f".../records/{record_id}"
|
||||||
|
return requests.delete(url, headers=headers).json()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 筛选条件
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 等于
|
||||||
|
'CurrentValue.[状态]="进行中"'
|
||||||
|
|
||||||
|
# 包含
|
||||||
|
'CurrentValue.[标题].contains("任务")'
|
||||||
|
|
||||||
|
# 大于(数字/日期)
|
||||||
|
'CurrentValue.[优先级]>2'
|
||||||
|
|
||||||
|
# 组合
|
||||||
|
'AND(CurrentValue.[状态]="进行中", CurrentValue.[优先级]>2)'
|
||||||
|
'OR(CurrentValue.[状态]="完成", CurrentValue.[状态]="归档")'
|
||||||
|
|
||||||
|
# 空值
|
||||||
|
'CurrentValue.[截止日期]=BLANK()'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 排序
|
||||||
|
|
||||||
|
```python
|
||||||
|
params = {
|
||||||
|
"sort": '[{"field_name":"优先级","desc":true}]'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 字段类型
|
||||||
|
|
||||||
|
| 类型 | 值格式 |
|
||||||
|
|------|--------|
|
||||||
|
| 文本 | `"字符串"` |
|
||||||
|
| 数字 | `123` |
|
||||||
|
| 单选 | `"选项值"` |
|
||||||
|
| 多选 | `["选项1", "选项2"]` |
|
||||||
|
| 日期 | `1706400000000` (毫秒时间戳) |
|
||||||
|
| 人员 | `[{"id": "ou_xxx"}]` |
|
||||||
|
| 复选框 | `true/false` |
|
||||||
|
| 链接 | `{"link": "https://...", "text": "显示文本"}` |
|
||||||
|
|
||||||
|
## 完整工具类
|
||||||
|
|
||||||
|
见 `~/.claude/skills/feishu/feishu_bitable.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from feishu_bitable import FeishuBitable
|
||||||
|
|
||||||
|
bitable = FeishuBitable()
|
||||||
|
records = bitable.list_records("BascXXX", "tblXXX")
|
||||||
|
bitable.create_record("BascXXX", "tblXXX", {"名称": "测试"})
|
||||||
|
bitable.batch_create("BascXXX", "tblXXX", [{"名称": "1"}, {"名称": "2"}])
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见场景
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 获取待处理任务
|
||||||
|
tasks = bitable.list_records("BascXXX", "tblXXX",
|
||||||
|
filter_str='CurrentValue.[状态]="待处理"')
|
||||||
|
|
||||||
|
# 批量更新状态
|
||||||
|
for task in tasks:
|
||||||
|
bitable.update_record("BascXXX", "tblXXX",
|
||||||
|
task["record_id"], {"状态": "已完成"})
|
||||||
|
```
|
||||||
8
plugins/feishu-docx-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/feishu-docx-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "feishu-docx-plugin",
|
||||||
|
"description": "飞书云文档操作。用于创建、编辑云文档,插入内容块,会议纪要生成。当需要操作飞书云文档时使用。",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
126
plugins/feishu-docx-plugin/skills/SKILL.md
Normal file
126
plugins/feishu-docx-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
name: feishu-docx
|
||||||
|
description: 飞书云文档操作。用于创建、编辑云文档,插入内容块,会议纪要生成。当需要操作飞书云文档时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 飞书云文档 (Docx)
|
||||||
|
|
||||||
|
## URL 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
https://xxx.feishu.cn/docx/DoxcXXXXXX
|
||||||
|
└── document_id
|
||||||
|
```
|
||||||
|
|
||||||
|
## 创建文档
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_document(title, folder_token=None):
|
||||||
|
url = "https://open.feishu.cn/open-apis/docx/v1/documents"
|
||||||
|
payload = {"title": title}
|
||||||
|
if folder_token:
|
||||||
|
payload["folder_token"] = folder_token
|
||||||
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
|
doc = response.json()["data"]["document"]
|
||||||
|
return {"document_id": doc["document_id"], "url": f"https://feishu.cn/docx/{doc['document_id']}"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设置权限
|
||||||
|
|
||||||
|
```python
|
||||||
|
def set_permission(document_id, editable=True):
|
||||||
|
"""设置文档为组织内可编辑/只读"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{document_id}/public"
|
||||||
|
payload = {
|
||||||
|
"external_access_entity": "open",
|
||||||
|
"link_share_entity": "tenant_editable" if editable else "tenant_readable"
|
||||||
|
}
|
||||||
|
requests.patch(url, headers=headers, params={"type": "docx"}, json=payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 内容块类型
|
||||||
|
|
||||||
|
| 类型 | block_type | 示例 |
|
||||||
|
|------|------------|------|
|
||||||
|
| 段落 | 2 | 普通文本 |
|
||||||
|
| 一级标题 | 3 | # 标题 |
|
||||||
|
| 二级标题 | 4 | ## 标题 |
|
||||||
|
| 三级标题 | 5 | ### 标题 |
|
||||||
|
| 无序列表 | 13 | - 列表项 |
|
||||||
|
| 有序列表 | 14 | 1. 列表项 |
|
||||||
|
| 代码块 | 16 | ```code``` |
|
||||||
|
| 引用 | 18 | > 引用 |
|
||||||
|
| 分割线 | 22 | --- |
|
||||||
|
| 图片 | 27 | 需先上传 |
|
||||||
|
|
||||||
|
## 创建内容块
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_block(document_id, block_id, block_type, content):
|
||||||
|
url = f"https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children"
|
||||||
|
|
||||||
|
if block_type in [3, 4, 5]: # 标题
|
||||||
|
block = {"block_type": block_type, "heading": {"elements": [{"text_run": {"content": content}}]}}
|
||||||
|
elif block_type == 2: # 段落
|
||||||
|
block = {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
|
||||||
|
elif block_type in [13, 14]: # 列表
|
||||||
|
block = {"block_type": block_type, "bullet/ordered": {"elements": [{"text_run": {"content": content}}]}}
|
||||||
|
|
||||||
|
requests.post(url, headers=headers, json={"children": [block], "index": -1})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 图片上传
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. 上传图片到素材库
|
||||||
|
def upload_image(file_path, parent_node):
|
||||||
|
url = "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all"
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': f}
|
||||||
|
data = {'file_name': os.path.basename(file_path), 'parent_type': 'docx_image', 'parent_node': parent_node}
|
||||||
|
response = requests.post(url, headers={"Authorization": f"Bearer {token}"}, files=files, data=data)
|
||||||
|
return response.json()["data"]["file_token"]
|
||||||
|
|
||||||
|
# 2. 插入图片块
|
||||||
|
def insert_image(document_id, block_id, file_token):
|
||||||
|
block = {"block_type": 27, "image": {"token": file_token}}
|
||||||
|
# ... 同 create_block
|
||||||
|
```
|
||||||
|
|
||||||
|
## 会议纪要模板
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_meeting_notes(title, date, attendees, agenda, decisions, action_items):
|
||||||
|
doc = create_document(f"{title} - {date}")
|
||||||
|
doc_id = doc["document_id"]
|
||||||
|
|
||||||
|
# 获取根块
|
||||||
|
root = requests.get(f".../documents/{doc_id}/blocks/{doc_id}").json()
|
||||||
|
root_id = root["data"]["block"]["block_id"]
|
||||||
|
|
||||||
|
# 添加内容
|
||||||
|
create_block(doc_id, root_id, 3, f"会议纪要:{title}")
|
||||||
|
create_block(doc_id, root_id, 2, f"日期:{date}")
|
||||||
|
create_block(doc_id, root_id, 2, f"参会人:{', '.join(attendees)}")
|
||||||
|
create_block(doc_id, root_id, 4, "议程")
|
||||||
|
for item in agenda:
|
||||||
|
create_block(doc_id, root_id, 13, item)
|
||||||
|
create_block(doc_id, root_id, 4, "决议")
|
||||||
|
for item in decisions:
|
||||||
|
create_block(doc_id, root_id, 13, item)
|
||||||
|
create_block(doc_id, root_id, 4, "待办事项")
|
||||||
|
for item in action_items:
|
||||||
|
create_block(doc_id, root_id, 14, item)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整工具类
|
||||||
|
|
||||||
|
见 `~/.claude/skills/feishu/feishu_docx.py`
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 创建文档必须指定 `folder_token`,否则会出现在「与我共享」
|
||||||
|
- 默认存储到 `C80gfkRnzlonQ5d4AhOcOACDnNg`(01运营文件夹)
|
||||||
|
- 图片必须先上传到素材库,再插入文档
|
||||||
8
plugins/feishu-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/feishu-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "feishu-plugin",
|
||||||
|
"description": "飞书多维表格快捷操作。通过自然语言实现多维表格的增删改查、数据同步、批量操作等功能。当用户提到飞书、多维表格、Bitable、飞书表格相关任务时自动激活。",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
322
plugins/feishu-plugin/add_images.py
Normal file
322
plugins/feishu-plugin/add_images.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
添加需求图片字段并上传图片到飞书多维表格
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# ========== 配置 ==========
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
# Demo 创建的多维表格信息
|
||||||
|
APP_TOKEN = "D6PQbxf4aald77sPjDTciYbenjc"
|
||||||
|
TABLE_ID = "tblX3YbGrXm8pmLR"
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuBitable:
|
||||||
|
"""飞书多维表格操作工具类"""
|
||||||
|
|
||||||
|
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
|
||||||
|
self.app_id = app_id
|
||||||
|
self.app_secret = app_secret
|
||||||
|
self._token = None
|
||||||
|
self._token_expires = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self) -> str:
|
||||||
|
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": self.app_id,
|
||||||
|
"app_secret": self.app_secret
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
self._token = data["tenant_access_token"]
|
||||||
|
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self):
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_field(self, app_token: str, table_id: str, field_name: str, field_type: int, property: dict = None):
|
||||||
|
"""创建新字段"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||||
|
payload = {
|
||||||
|
"field_name": field_name,
|
||||||
|
"type": field_type
|
||||||
|
}
|
||||||
|
if property:
|
||||||
|
payload["property"] = property
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self.headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建字段失败: {data}")
|
||||||
|
|
||||||
|
print(f"[OK] 字段 '{field_name}' 创建成功")
|
||||||
|
return data["data"]["field"]
|
||||||
|
|
||||||
|
def upload_media(self, app_token: str, file_path: str, file_name: str = None):
|
||||||
|
"""上传附件到多维表格"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
|
||||||
|
if file_name is None:
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.token}"
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {
|
||||||
|
'file': (file_name, f, 'image/png')
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': 'bitable_image',
|
||||||
|
'parent_node': app_token,
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, files=files, data=data)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise Exception(f"上传失败: {result}")
|
||||||
|
|
||||||
|
file_token = result["data"]["file_token"]
|
||||||
|
print(f"[OK] 文件 '{file_name}' 上传成功, file_token: {file_token}")
|
||||||
|
return file_token
|
||||||
|
|
||||||
|
def list_records(self, app_token: str, table_id: str, filter_str: str = None):
|
||||||
|
"""列出记录"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
params = {"page_size": 100}
|
||||||
|
if filter_str:
|
||||||
|
params["filter"] = filter_str
|
||||||
|
|
||||||
|
response = requests.get(url, headers=self.headers, params=params)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"查询失败: {data}")
|
||||||
|
|
||||||
|
return data["data"].get("items", [])
|
||||||
|
|
||||||
|
def update_record(self, app_token: str, table_id: str, record_id: str, fields: dict):
|
||||||
|
"""更新记录"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||||||
|
response = requests.put(url, headers=self.headers, json={"fields": fields})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"更新失败: {data}")
|
||||||
|
|
||||||
|
print(f"[OK] 记录 {record_id} 更新成功")
|
||||||
|
return data["data"]["record"]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_sample_images(output_dir: str):
|
||||||
|
"""生成2张示例需求图片"""
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
images = []
|
||||||
|
|
||||||
|
# 图片1: 用户流程图
|
||||||
|
img1 = Image.new('RGB', (800, 600), color='#f0f4f8')
|
||||||
|
draw1 = ImageDraw.Draw(img1)
|
||||||
|
|
||||||
|
# 绘制标题
|
||||||
|
draw1.rectangle([50, 30, 750, 80], fill='#4a90d9', outline='#2563eb')
|
||||||
|
draw1.text((400, 55), "用户登录流程图", fill='white', anchor='mm')
|
||||||
|
|
||||||
|
# 绘制流程框
|
||||||
|
boxes = [
|
||||||
|
(150, 150, "打开登录页"),
|
||||||
|
(400, 150, "输入账号密码"),
|
||||||
|
(650, 150, "点击登录"),
|
||||||
|
(150, 300, "验证账号"),
|
||||||
|
(400, 300, "生成Token"),
|
||||||
|
(650, 300, "跳转首页"),
|
||||||
|
]
|
||||||
|
for x, y, text in boxes:
|
||||||
|
draw1.rectangle([x-60, y-25, x+60, y+25], fill='#e8f4fd', outline='#4a90d9', width=2)
|
||||||
|
draw1.text((x, y), text, fill='#1e3a5f', anchor='mm')
|
||||||
|
|
||||||
|
# 绘制箭头线
|
||||||
|
arrows = [
|
||||||
|
(210, 150, 340, 150),
|
||||||
|
(460, 150, 590, 150),
|
||||||
|
(650, 175, 650, 275),
|
||||||
|
(590, 300, 460, 300),
|
||||||
|
(340, 300, 210, 300),
|
||||||
|
]
|
||||||
|
for x1, y1, x2, y2 in arrows:
|
||||||
|
draw1.line([(x1, y1), (x2, y2)], fill='#4a90d9', width=2)
|
||||||
|
|
||||||
|
# 添加水印
|
||||||
|
draw1.text((400, 550), "Claude Code Demo - 需求图片1", fill='#94a3b8', anchor='mm')
|
||||||
|
|
||||||
|
img1_path = os.path.join(output_dir, "requirement_flow.png")
|
||||||
|
img1.save(img1_path)
|
||||||
|
images.append(img1_path)
|
||||||
|
print(f"[OK] 生成图片: {img1_path}")
|
||||||
|
|
||||||
|
# 图片2: 界面原型图
|
||||||
|
img2 = Image.new('RGB', (800, 600), color='#ffffff')
|
||||||
|
draw2 = ImageDraw.Draw(img2)
|
||||||
|
|
||||||
|
# 绘制浏览器框架
|
||||||
|
draw2.rectangle([50, 30, 750, 570], outline='#d1d5db', width=2)
|
||||||
|
draw2.rectangle([50, 30, 750, 70], fill='#f3f4f6', outline='#d1d5db')
|
||||||
|
|
||||||
|
# 浏览器按钮
|
||||||
|
draw2.ellipse([70, 42, 86, 58], fill='#ef4444')
|
||||||
|
draw2.ellipse([95, 42, 111, 58], fill='#eab308')
|
||||||
|
draw2.ellipse([120, 42, 136, 58], fill='#22c55e')
|
||||||
|
|
||||||
|
# 地址栏
|
||||||
|
draw2.rectangle([160, 42, 600, 58], fill='white', outline='#d1d5db')
|
||||||
|
draw2.text((170, 50), "https://example.com/login", fill='#6b7280', anchor='lm')
|
||||||
|
|
||||||
|
# 登录表单区域
|
||||||
|
draw2.rectangle([200, 120, 600, 500], fill='#f8fafc', outline='#e2e8f0', width=1)
|
||||||
|
|
||||||
|
# Logo 占位
|
||||||
|
draw2.ellipse([350, 140, 450, 200], fill='#4a90d9')
|
||||||
|
draw2.text((400, 170), "LOGO", fill='white', anchor='mm')
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
draw2.text((400, 230), "欢迎登录", fill='#1e293b', anchor='mm')
|
||||||
|
|
||||||
|
# 输入框
|
||||||
|
draw2.rectangle([250, 270, 550, 310], fill='white', outline='#cbd5e1')
|
||||||
|
draw2.text((260, 290), "请输入用户名", fill='#94a3b8', anchor='lm')
|
||||||
|
|
||||||
|
draw2.rectangle([250, 330, 550, 370], fill='white', outline='#cbd5e1')
|
||||||
|
draw2.text((260, 350), "请输入密码", fill='#94a3b8', anchor='lm')
|
||||||
|
|
||||||
|
# 登录按钮
|
||||||
|
draw2.rectangle([250, 400, 550, 450], fill='#4a90d9', outline='#2563eb')
|
||||||
|
draw2.text((400, 425), "登 录", fill='white', anchor='mm')
|
||||||
|
|
||||||
|
# 底部链接
|
||||||
|
draw2.text((320, 480), "忘记密码", fill='#4a90d9', anchor='mm')
|
||||||
|
draw2.text((480, 480), "注册账号", fill='#4a90d9', anchor='mm')
|
||||||
|
|
||||||
|
# 水印
|
||||||
|
draw2.text((400, 550), "Claude Code Demo - 需求图片2", fill='#94a3b8', anchor='mm')
|
||||||
|
|
||||||
|
img2_path = os.path.join(output_dir, "requirement_ui.png")
|
||||||
|
img2.save(img2_path)
|
||||||
|
images.append(img2_path)
|
||||||
|
print(f"[OK] 生成图片: {img2_path}")
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "#" * 60)
|
||||||
|
print("# 添加需求图片到多维表格")
|
||||||
|
print("#" * 60)
|
||||||
|
|
||||||
|
bitable = FeishuBitable()
|
||||||
|
|
||||||
|
# Step 1: 生成示例图片
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 1: 生成示例图片")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
output_dir = "/tmp/feishu_demo_images"
|
||||||
|
image_paths = generate_sample_images(output_dir)
|
||||||
|
|
||||||
|
# Step 2: 添加"需求图片"字段
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 2: 添加「需求图片」字段")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
bitable.create_field(
|
||||||
|
APP_TOKEN,
|
||||||
|
TABLE_ID,
|
||||||
|
"需求图片",
|
||||||
|
17 # 17 = 附件类型
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if "FieldNameExist" in str(e) or "FieldNameDuplicated" in str(e) or "1254043" in str(e) or "1254014" in str(e):
|
||||||
|
print("[INFO] 字段「需求图片」已存在,跳过创建")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Step 3: 上传图片
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 3: 上传图片到飞书")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
file_tokens = []
|
||||||
|
for img_path in image_paths:
|
||||||
|
file_token = bitable.upload_media(APP_TOKEN, img_path)
|
||||||
|
file_tokens.append(file_token)
|
||||||
|
|
||||||
|
# Step 4: 查找"完成产品需求文档"记录
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 4: 查找目标记录")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
records = bitable.list_records(APP_TOKEN, TABLE_ID)
|
||||||
|
target_record = None
|
||||||
|
for record in records:
|
||||||
|
if record["fields"].get("任务名称") == "完成产品需求文档":
|
||||||
|
target_record = record
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_record:
|
||||||
|
raise Exception("未找到「完成产品需求文档」记录")
|
||||||
|
|
||||||
|
print(f"[OK] 找到记录: {target_record['record_id']}")
|
||||||
|
print(f" 任务名称: {target_record['fields'].get('任务名称')}")
|
||||||
|
|
||||||
|
# Step 5: 更新记录,添加图片
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 5: 更新记录,添加图片附件")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# 构造附件字段值
|
||||||
|
attachments = [{"file_token": ft} for ft in file_tokens]
|
||||||
|
|
||||||
|
bitable.update_record(
|
||||||
|
APP_TOKEN,
|
||||||
|
TABLE_ID,
|
||||||
|
target_record["record_id"],
|
||||||
|
{"需求图片": attachments}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 完成
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"\n已添加 {len(file_tokens)} 张图片到「完成产品需求文档」记录")
|
||||||
|
print(f"\n访问地址查看效果:")
|
||||||
|
print(f" https://zhiyuncai.feishu.cn/base/{APP_TOKEN}?table={TABLE_ID}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
530
plugins/feishu-plugin/aiproj_sync.py
Normal file
530
plugins/feishu-plugin/aiproj_sync.py
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ai-proj 与飞书项目同步脚本
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 初始化:创建飞书根目录、多维表格
|
||||||
|
- 全量同步:同步所有项目到飞书
|
||||||
|
- 增量同步:只同步新增项目
|
||||||
|
- 更新同步:更新现有项目的任务统计等信息
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
python aiproj_sync.py init # 首次初始化
|
||||||
|
python aiproj_sync.py sync # 增量同步(推荐日常使用)
|
||||||
|
python aiproj_sync.py sync-all # 全量同步
|
||||||
|
python aiproj_sync.py update # 更新统计信息
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
|
||||||
|
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
|
||||||
|
|
||||||
|
# 默认协作者 open_id(邱栋梁@智云采购)
|
||||||
|
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
|
||||||
|
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
|
||||||
|
|
||||||
|
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
|
||||||
|
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
|
||||||
|
|
||||||
|
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 飞书 API
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class FeishuAPI:
|
||||||
|
def __init__(self):
|
||||||
|
self.token = None
|
||||||
|
|
||||||
|
def get_token(self) -> str:
|
||||||
|
"""获取 tenant_access_token"""
|
||||||
|
if self.token:
|
||||||
|
return self.token
|
||||||
|
|
||||||
|
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": FEISHU_APP_ID,
|
||||||
|
"app_secret": FEISHU_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
self.token = data["tenant_access_token"]
|
||||||
|
return self.token
|
||||||
|
else:
|
||||||
|
raise Exception(f"获取飞书 token 失败: {data}")
|
||||||
|
|
||||||
|
def _headers(self, content_type: bool = False) -> Dict:
|
||||||
|
headers = {"Authorization": f"Bearer {self.get_token()}"}
|
||||||
|
if content_type:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def get_root_folder(self) -> str:
|
||||||
|
"""获取云空间根文件夹"""
|
||||||
|
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
|
||||||
|
response = requests.get(url, headers=self._headers())
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["token"]
|
||||||
|
raise Exception(f"获取根文件夹失败: {data}")
|
||||||
|
|
||||||
|
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
|
||||||
|
"""创建文件夹"""
|
||||||
|
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
|
||||||
|
response = requests.post(url, headers=self._headers(True), json={
|
||||||
|
"name": name,
|
||||||
|
"folder_token": parent_token
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
folder_token = data["data"]["token"]
|
||||||
|
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
|
||||||
|
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
|
||||||
|
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
|
||||||
|
return folder_token
|
||||||
|
raise Exception(f"创建文件夹失败: {data}")
|
||||||
|
|
||||||
|
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
|
||||||
|
"""添加协作者
|
||||||
|
|
||||||
|
应用通过 API 创建的文件/文件夹,所有者是应用本身。
|
||||||
|
组织内用户默认无法访问,需要显式添加为协作者。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_token: 文件/文件夹 token
|
||||||
|
file_type: 类型 (folder, doc, sheet, bitable 等)
|
||||||
|
user_open_id: 用户 open_id
|
||||||
|
perm: 权限级别 (full_access, edit, view)
|
||||||
|
"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
|
||||||
|
params = {"type": file_type, "need_notification": "false"}
|
||||||
|
payload = {
|
||||||
|
"member_type": "openid",
|
||||||
|
"member_id": user_open_id,
|
||||||
|
"perm": perm
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
|
||||||
|
return response.json().get("code") == 0
|
||||||
|
|
||||||
|
def create_bitable(self, name: str, folder_token: str) -> Dict:
|
||||||
|
"""创建多维表格"""
|
||||||
|
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
|
||||||
|
response = requests.post(url, headers=self._headers(True), json={
|
||||||
|
"name": name,
|
||||||
|
"folder_token": folder_token
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["app"]
|
||||||
|
raise Exception(f"创建多维表格失败: {data}")
|
||||||
|
|
||||||
|
def get_bitable_tables(self, app_token: str) -> List[Dict]:
|
||||||
|
"""获取数据表列表"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
|
||||||
|
response = requests.get(url, headers=self._headers())
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["items"]
|
||||||
|
raise Exception(f"获取数据表失败: {data}")
|
||||||
|
|
||||||
|
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
|
||||||
|
"""创建字段"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||||
|
response = requests.post(url, headers=self._headers(True), json=field)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["field"]
|
||||||
|
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
|
||||||
|
"""更新字段"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
|
||||||
|
response = requests.put(url, headers=self._headers(True), json=updates)
|
||||||
|
return response.json().get("code") == 0
|
||||||
|
|
||||||
|
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
|
||||||
|
"""获取字段列表"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||||
|
response = requests.get(url, headers=self._headers())
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["items"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
|
||||||
|
"""添加记录"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["record"]
|
||||||
|
print(f" 记录添加失败: {data.get('msg')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
|
||||||
|
"""获取记录列表"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
params = {"page_size": 500}
|
||||||
|
if filter_str:
|
||||||
|
params["filter"] = filter_str
|
||||||
|
|
||||||
|
response = requests.get(url, headers=self._headers(), params=params)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"].get("items", [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
|
||||||
|
"""更新记录"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||||||
|
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
|
||||||
|
return response.json().get("code") == 0
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ai-proj API
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class AIProjAPI:
|
||||||
|
@staticmethod
|
||||||
|
def get_projects() -> List[Dict]:
|
||||||
|
"""获取项目列表"""
|
||||||
|
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
|
||||||
|
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("success"):
|
||||||
|
return data["data"]["data"]
|
||||||
|
raise Exception(f"获取项目列表失败: {data}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 配置管理
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def load_config() -> Optional[Dict]:
|
||||||
|
"""加载配置"""
|
||||||
|
if os.path.exists(CONFIG_PATH):
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_config(config: Dict):
|
||||||
|
"""保存配置"""
|
||||||
|
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||||
|
with open(CONFIG_PATH, "w") as f:
|
||||||
|
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 同步逻辑
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def init():
|
||||||
|
"""初始化:创建飞书目录结构"""
|
||||||
|
print("=" * 50)
|
||||||
|
print("飞书 ai-proj 项目同步 - 初始化")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
if load_config():
|
||||||
|
print("\n已存在配置文件,是否重新初始化?(y/N)")
|
||||||
|
if input().lower() != 'y':
|
||||||
|
print("取消初始化")
|
||||||
|
return
|
||||||
|
|
||||||
|
feishu = FeishuAPI()
|
||||||
|
|
||||||
|
# 1. 获取根目录
|
||||||
|
print("\n[1/5] 获取云空间根目录...")
|
||||||
|
space_root = feishu.get_root_folder()
|
||||||
|
print(f" 根目录: {space_root}")
|
||||||
|
|
||||||
|
# 2. 创建 ai-proj 文件夹
|
||||||
|
print("\n[2/5] 创建 ai-proj 根文件夹...")
|
||||||
|
root_folder = feishu.create_folder("ai-proj", space_root)
|
||||||
|
print(f" 创建成功: {root_folder}")
|
||||||
|
|
||||||
|
# 3. 创建多维表格
|
||||||
|
print("\n[3/5] 创建项目目录多维表格...")
|
||||||
|
bitable = feishu.create_bitable("项目目录", root_folder)
|
||||||
|
app_token = bitable["app_token"]
|
||||||
|
print(f" 创建成功: {app_token}")
|
||||||
|
|
||||||
|
tables = feishu.get_bitable_tables(app_token)
|
||||||
|
table_id = tables[0]["table_id"]
|
||||||
|
|
||||||
|
# 4. 创建字段
|
||||||
|
print("\n[4/5] 创建数据表字段...")
|
||||||
|
|
||||||
|
# 先修改第一列名称
|
||||||
|
fields = feishu.get_fields(app_token, table_id)
|
||||||
|
if fields:
|
||||||
|
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
|
||||||
|
"field_name": "项目名称",
|
||||||
|
"type": fields[0]["type"]
|
||||||
|
})
|
||||||
|
print(" 修改字段: 项目名称")
|
||||||
|
|
||||||
|
# 创建其他字段
|
||||||
|
field_definitions = [
|
||||||
|
{"field_name": "项目ID", "type": 2},
|
||||||
|
{"field_name": "项目编号", "type": 1},
|
||||||
|
{"field_name": "飞书文件夹", "type": 15},
|
||||||
|
{"field_name": "状态", "type": 3, "property": {"options": [
|
||||||
|
{"name": "active", "color": 0},
|
||||||
|
{"name": "on_hold", "color": 1},
|
||||||
|
{"name": "planning", "color": 2},
|
||||||
|
{"name": "completed", "color": 3},
|
||||||
|
{"name": "archived", "color": 4}
|
||||||
|
]}},
|
||||||
|
{"field_name": "优先级", "type": 3, "property": {"options": [
|
||||||
|
{"name": "high", "color": 0},
|
||||||
|
{"name": "medium", "color": 1},
|
||||||
|
{"name": "low", "color": 2}
|
||||||
|
]}},
|
||||||
|
{"field_name": "所属企业", "type": 1},
|
||||||
|
{"field_name": "任务总数", "type": 2},
|
||||||
|
{"field_name": "描述", "type": 1},
|
||||||
|
{"field_name": "创建时间", "type": 5},
|
||||||
|
{"field_name": "最后同步", "type": 5},
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in field_definitions:
|
||||||
|
if feishu.create_field(app_token, table_id, field):
|
||||||
|
print(f" 创建字段: {field['field_name']}")
|
||||||
|
|
||||||
|
# 5. 保存配置
|
||||||
|
print("\n[5/5] 保存配置...")
|
||||||
|
config = {
|
||||||
|
"root_folder_token": root_folder,
|
||||||
|
"bitable_app_token": app_token,
|
||||||
|
"bitable_table_id": table_id,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"synced_projects": {}
|
||||||
|
}
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("初始化完成!")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
|
||||||
|
print(f"多维表格: https://feishu.cn/base/{app_token}")
|
||||||
|
print(f"\n配置文件: {CONFIG_PATH}")
|
||||||
|
print("\n运行 'python aiproj_sync.py sync' 同步项目")
|
||||||
|
|
||||||
|
def sync(full: bool = False):
|
||||||
|
"""同步项目"""
|
||||||
|
config = load_config()
|
||||||
|
if not config:
|
||||||
|
print("未找到配置,请先运行 init")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
feishu = FeishuAPI()
|
||||||
|
app_token = config["bitable_app_token"]
|
||||||
|
table_id = config["bitable_table_id"]
|
||||||
|
root_folder = config["root_folder_token"]
|
||||||
|
synced = config.get("synced_projects", {})
|
||||||
|
|
||||||
|
# 获取 ai-proj 项目
|
||||||
|
print("\n获取 ai-proj 项目列表...")
|
||||||
|
projects = AIProjAPI.get_projects()
|
||||||
|
print(f" 共 {len(projects)} 个项目")
|
||||||
|
|
||||||
|
# 筛选需要同步的项目
|
||||||
|
if full:
|
||||||
|
to_sync = projects
|
||||||
|
else:
|
||||||
|
to_sync = [p for p in projects if str(p["id"]) not in synced]
|
||||||
|
|
||||||
|
if not to_sync:
|
||||||
|
print("\n没有新项目需要同步")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n需要同步 {len(to_sync)} 个项目...")
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
for project in to_sync:
|
||||||
|
project_id = str(project["id"])
|
||||||
|
project_name = project["name"]
|
||||||
|
folder_name = f"{project_name}_{project_id}"
|
||||||
|
|
||||||
|
print(f"\n 处理: {project_name} (ID: {project_id})")
|
||||||
|
|
||||||
|
# 创建文件夹(如果不存在)
|
||||||
|
folder_token = synced.get(project_id, {}).get("folder_token")
|
||||||
|
if not folder_token:
|
||||||
|
try:
|
||||||
|
folder_token = feishu.create_folder(folder_name, root_folder)
|
||||||
|
print(f" 创建文件夹: {folder_token}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 文件夹创建失败: {e}")
|
||||||
|
folder_token = ""
|
||||||
|
|
||||||
|
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
|
||||||
|
|
||||||
|
# 添加/更新记录
|
||||||
|
record_fields = {
|
||||||
|
"项目名称": project_name,
|
||||||
|
"项目ID": int(project_id),
|
||||||
|
"项目编号": project.get("project_number", ""),
|
||||||
|
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
|
||||||
|
"状态": project.get("status", "active"),
|
||||||
|
"优先级": project.get("priority", "medium"),
|
||||||
|
"所属企业": project.get("company_name", ""),
|
||||||
|
"任务总数": project.get("task_count", 0),
|
||||||
|
"描述": (project.get("description", "") or "")[:500],
|
||||||
|
"创建时间": int(datetime.fromisoformat(
|
||||||
|
project["created_at"].replace("Z", "+00:00")
|
||||||
|
).timestamp() * 1000),
|
||||||
|
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
record_id = synced.get(project_id, {}).get("record_id")
|
||||||
|
if record_id and not full:
|
||||||
|
# 更新现有记录
|
||||||
|
if feishu.update_record(app_token, table_id, record_id, record_fields):
|
||||||
|
print(f" 更新记录成功")
|
||||||
|
success += 1
|
||||||
|
else:
|
||||||
|
# 添加新记录
|
||||||
|
record = feishu.add_record(app_token, table_id, record_fields)
|
||||||
|
if record:
|
||||||
|
record_id = record["record_id"]
|
||||||
|
print(f" 添加记录成功")
|
||||||
|
success += 1
|
||||||
|
|
||||||
|
# 更新同步记录
|
||||||
|
synced[project_id] = {
|
||||||
|
"folder_token": folder_token,
|
||||||
|
"folder_url": folder_url,
|
||||||
|
"record_id": record_id,
|
||||||
|
"synced_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 保存配置
|
||||||
|
config["synced_projects"] = synced
|
||||||
|
config["last_sync"] = datetime.now().isoformat()
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"同步完成: {success}/{len(to_sync)}")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
|
||||||
|
|
||||||
|
def update_stats():
|
||||||
|
"""更新任务统计信息"""
|
||||||
|
config = load_config()
|
||||||
|
if not config:
|
||||||
|
print("未找到配置,请先运行 init")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("飞书 ai-proj 项目同步 - 更新统计")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
feishu = FeishuAPI()
|
||||||
|
app_token = config["bitable_app_token"]
|
||||||
|
table_id = config["bitable_table_id"]
|
||||||
|
synced = config.get("synced_projects", {})
|
||||||
|
|
||||||
|
# 获取最新项目数据
|
||||||
|
print("\n获取 ai-proj 项目列表...")
|
||||||
|
projects = AIProjAPI.get_projects()
|
||||||
|
project_map = {str(p["id"]): p for p in projects}
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for project_id, sync_info in synced.items():
|
||||||
|
record_id = sync_info.get("record_id")
|
||||||
|
if not record_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
project = project_map.get(project_id)
|
||||||
|
if not project:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 只更新统计字段
|
||||||
|
update_fields = {
|
||||||
|
"任务总数": project.get("task_count", 0),
|
||||||
|
"状态": project.get("status", "active"),
|
||||||
|
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feishu.update_record(app_token, table_id, record_id, update_fields):
|
||||||
|
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
config["last_sync"] = datetime.now().isoformat()
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
print(f"\n更新完成: {updated} 个项目")
|
||||||
|
|
||||||
|
def show_status():
|
||||||
|
"""显示同步状态"""
|
||||||
|
config = load_config()
|
||||||
|
if not config:
|
||||||
|
print("未初始化,请先运行: python aiproj_sync.py init")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("ai-proj 飞书同步状态")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
|
||||||
|
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
|
||||||
|
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
|
||||||
|
print(f"上次同步: {config.get('last_sync', '从未')}")
|
||||||
|
print(f"配置文件: {CONFIG_PATH}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 主入口
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("用法: python aiproj_sync.py <command>")
|
||||||
|
print("\n命令:")
|
||||||
|
print(" init 首次初始化(创建目录和多维表格)")
|
||||||
|
print(" sync 增量同步(只同步新项目)")
|
||||||
|
print(" sync-all 全量同步(同步所有项目)")
|
||||||
|
print(" update 更新统计信息")
|
||||||
|
print(" status 查看同步状态")
|
||||||
|
return
|
||||||
|
|
||||||
|
command = sys.argv[1]
|
||||||
|
|
||||||
|
if command == "init":
|
||||||
|
init()
|
||||||
|
elif command == "sync":
|
||||||
|
sync(full=False)
|
||||||
|
elif command == "sync-all":
|
||||||
|
sync(full=True)
|
||||||
|
elif command == "update":
|
||||||
|
update_stats()
|
||||||
|
elif command == "status":
|
||||||
|
show_status()
|
||||||
|
else:
|
||||||
|
print(f"未知命令: {command}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
104
plugins/feishu-plugin/check_docx_image.py
Normal file
104
plugins/feishu-plugin/check_docx_image.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
检查飞书云文档中的图片块状态
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
# 最近创建的测试文档
|
||||||
|
DOCUMENT_ID = "Z53YdDpezob1NPx63sQcsrt8nzd"
|
||||||
|
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": ZHIYUN_APP_ID,
|
||||||
|
"app_secret": ZHIYUN_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
return data["tenant_access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_blocks(document_id: str):
|
||||||
|
"""获取文档所有块"""
|
||||||
|
token = get_token()
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取块失败: {data}")
|
||||||
|
|
||||||
|
return data["data"].get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"\n检查文档: {DOCUMENT_ID}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
blocks = get_document_blocks(DOCUMENT_ID)
|
||||||
|
|
||||||
|
print(f"\n文档共有 {len(blocks)} 个块:\n")
|
||||||
|
|
||||||
|
for i, block in enumerate(blocks):
|
||||||
|
block_type = block.get("block_type")
|
||||||
|
block_id = block.get("block_id")
|
||||||
|
|
||||||
|
# 块类型映射
|
||||||
|
type_names = {
|
||||||
|
1: "page",
|
||||||
|
2: "text",
|
||||||
|
3: "heading1",
|
||||||
|
4: "heading2",
|
||||||
|
5: "heading3",
|
||||||
|
12: "bullet",
|
||||||
|
13: "ordered",
|
||||||
|
14: "code",
|
||||||
|
17: "todo",
|
||||||
|
22: "divider",
|
||||||
|
27: "image",
|
||||||
|
}
|
||||||
|
type_name = type_names.get(block_type, f"type_{block_type}")
|
||||||
|
|
||||||
|
print(f" [{i}] block_type={block_type} ({type_name}), block_id={block_id[:20]}...")
|
||||||
|
|
||||||
|
# 如果是图片块,显示详细信息
|
||||||
|
if block_type == 27:
|
||||||
|
image_data = block.get("image", {})
|
||||||
|
print(f" image data: {image_data}")
|
||||||
|
|
||||||
|
# 检查图片是否有效
|
||||||
|
file_token = image_data.get("token") or image_data.get("file_token")
|
||||||
|
if file_token:
|
||||||
|
print(f" file_token: {file_token}")
|
||||||
|
# 尝试获取图片信息
|
||||||
|
check_image_status(file_token)
|
||||||
|
else:
|
||||||
|
print(f" [WARN] 图片块没有 token!")
|
||||||
|
|
||||||
|
|
||||||
|
def check_image_status(file_token: str):
|
||||||
|
"""检查图片状态"""
|
||||||
|
token = get_token()
|
||||||
|
|
||||||
|
# 尝试获取文件元信息
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/{file_token}"
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
print(f" 图片状态: code={data.get('code')}, msg={data.get('msg')}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
51
plugins/feishu-plugin/check_manual_images.py
Normal file
51
plugins/feishu-plugin/check_manual_images.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""检查文档中的图片块状态"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": ZHIYUN_APP_ID,
|
||||||
|
"app_secret": ZHIYUN_APP_SECRET
|
||||||
|
})
|
||||||
|
return response.json()["tenant_access_token"]
|
||||||
|
|
||||||
|
def main():
|
||||||
|
token = get_token()
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{DOCUMENT_ID}/blocks"
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
print(f"获取失败: {data}")
|
||||||
|
return
|
||||||
|
|
||||||
|
blocks = data["data"].get("items", [])
|
||||||
|
print(f"文档共有 {len(blocks)} 个块\n")
|
||||||
|
|
||||||
|
image_count = 0
|
||||||
|
for block in blocks:
|
||||||
|
if block.get("block_type") == 27:
|
||||||
|
image_count += 1
|
||||||
|
image_data = block.get("image", {})
|
||||||
|
token_value = image_data.get("token", "")
|
||||||
|
print(f"图片块 #{image_count}:")
|
||||||
|
print(f" block_id: {block.get('block_id')}")
|
||||||
|
print(f" token: {token_value if token_value else '(空)'}")
|
||||||
|
print(f" width: {image_data.get('width')}, height: {image_data.get('height')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if image_count == 0:
|
||||||
|
print("文档中没有图片块")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
423
plugins/feishu-plugin/create_visibility_manual.py
Normal file
423
plugins/feishu-plugin/create_visibility_manual.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
创建新的飞书文档:ai-proj 项目可见性手册
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
_token = None
|
||||||
|
_token_expires = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
global _token, _token_expires
|
||||||
|
if _token and _token_expires and datetime.now() < _token_expires:
|
||||||
|
return _token
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": ZHIYUN_APP_ID,
|
||||||
|
"app_secret": ZHIYUN_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
_token = data["tenant_access_token"]
|
||||||
|
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
return _token
|
||||||
|
|
||||||
|
|
||||||
|
def headers():
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {get_token()}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_document(title: str):
|
||||||
|
"""创建文档"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents"
|
||||||
|
response = requests.post(url, headers=headers(), json={"title": title})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建文档失败: {data}")
|
||||||
|
doc = data["data"]["document"]
|
||||||
|
document_id = doc["document_id"]
|
||||||
|
print(f"[OK] 文档创建成功: {document_id}")
|
||||||
|
return document_id
|
||||||
|
|
||||||
|
|
||||||
|
def set_document_permission(document_id: str):
|
||||||
|
"""设置文档权限为组织内可编辑"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||||
|
payload = {
|
||||||
|
"external_access_entity": "open",
|
||||||
|
"security_entity": "anyone_can_view",
|
||||||
|
"comment_entity": "anyone_can_view",
|
||||||
|
"share_entity": "anyone",
|
||||||
|
"link_share_entity": "tenant_editable",
|
||||||
|
"invite_external": False
|
||||||
|
}
|
||||||
|
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
print("[OK] 权限设置成功: 组织内可编辑")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[WARN] 权限设置: {result.get('msg')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_blocks(document_id: str):
|
||||||
|
"""获取文档所有块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||||
|
response = requests.get(url, headers=headers())
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取块失败: {data}")
|
||||||
|
return data["data"].get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||||||
|
"""创建内容块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||||
|
payload = {"children": blocks}
|
||||||
|
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建块失败: {data}")
|
||||||
|
return data["data"].get("children", [])
|
||||||
|
|
||||||
|
|
||||||
|
def create_image_block(document_id: str, parent_id: str):
|
||||||
|
"""创建空图片块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||||
|
payload = {
|
||||||
|
"children": [{
|
||||||
|
"block_type": 27,
|
||||||
|
"image": {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建图片块失败: {data}")
|
||||||
|
return data["data"]["children"][0]["block_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(file_path: str, block_id: str):
|
||||||
|
"""上传图片"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, 'image/png')}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': 'docx_image',
|
||||||
|
'parent_node': block_id,
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {get_token()}"},
|
||||||
|
files=files,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise Exception(f"上传失败: {result}")
|
||||||
|
return result["data"]["file_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||||
|
"""绑定图片到图片块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||||
|
payload = {"replace_image": {"token": file_token}}
|
||||||
|
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"绑定失败: {data}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def insert_image(document_id: str, file_path: str, description: str = ""):
|
||||||
|
"""插入图片的完整流程:创建块 -> 上传 -> 绑定"""
|
||||||
|
print(f" 插入图片: {description}")
|
||||||
|
|
||||||
|
# Step 1: 创建空图片块
|
||||||
|
block_id = create_image_block(document_id, document_id)
|
||||||
|
print(f" block_id: {block_id}")
|
||||||
|
|
||||||
|
# Step 2: 上传图片
|
||||||
|
file_token = upload_image(file_path, block_id)
|
||||||
|
print(f" file_token: {file_token}")
|
||||||
|
|
||||||
|
# Step 3: 绑定图片到块
|
||||||
|
bind_image(document_id, block_id, file_token)
|
||||||
|
print(f" 绑定成功!")
|
||||||
|
|
||||||
|
# 等待处理
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
return block_id, file_token
|
||||||
|
|
||||||
|
|
||||||
|
def generate_visibility_diagram():
|
||||||
|
"""生成可见性示意图"""
|
||||||
|
output_path = "/tmp/visibility_diagram.png"
|
||||||
|
|
||||||
|
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 尝试加载字体
|
||||||
|
try:
|
||||||
|
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||||||
|
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18)
|
||||||
|
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14)
|
||||||
|
except:
|
||||||
|
font_large = ImageFont.load_default()
|
||||||
|
font_medium = ImageFont.load_default()
|
||||||
|
font_small = ImageFont.load_default()
|
||||||
|
|
||||||
|
# 标题背景
|
||||||
|
draw.rectangle([0, 0, 800, 60], fill='#1890ff')
|
||||||
|
draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large)
|
||||||
|
|
||||||
|
# 私有项目区域
|
||||||
|
draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3)
|
||||||
|
draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium)
|
||||||
|
draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small)
|
||||||
|
draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 私有项目图标
|
||||||
|
draw.ellipse([175, 210, 245, 280], fill='#fa8c16')
|
||||||
|
draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large)
|
||||||
|
draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small)
|
||||||
|
draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 企业项目区域
|
||||||
|
draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3)
|
||||||
|
draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium)
|
||||||
|
draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small)
|
||||||
|
draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 企业项目图标组
|
||||||
|
positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)]
|
||||||
|
for x, y in positions:
|
||||||
|
draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff')
|
||||||
|
draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium)
|
||||||
|
|
||||||
|
draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 中间箭头
|
||||||
|
draw.text((400, 225), "→", fill='#333333', anchor='mm', font=font_large)
|
||||||
|
|
||||||
|
img.save(output_path)
|
||||||
|
print(f"[OK] 生成可见性示意图: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ui_screenshot():
|
||||||
|
"""生成 UI 示意图"""
|
||||||
|
output_path = "/tmp/visibility_ui.png"
|
||||||
|
|
||||||
|
img = Image.new('RGB', (700, 250), color='#fafafa')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
try:
|
||||||
|
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16)
|
||||||
|
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12)
|
||||||
|
except:
|
||||||
|
font_medium = ImageFont.load_default()
|
||||||
|
font_small = ImageFont.load_default()
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium)
|
||||||
|
|
||||||
|
# 私有项目选项(选中状态)
|
||||||
|
draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||||
|
draw.ellipse([45, 95, 65, 115], fill='#fa8c16')
|
||||||
|
draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium)
|
||||||
|
draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small)
|
||||||
|
|
||||||
|
# 默认标签
|
||||||
|
draw.rectangle([250, 85, 310, 110], fill='#52c41a')
|
||||||
|
draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 企业项目选项(未选中状态)
|
||||||
|
draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1)
|
||||||
|
draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2)
|
||||||
|
draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium)
|
||||||
|
draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small)
|
||||||
|
|
||||||
|
# 底部说明
|
||||||
|
draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1)
|
||||||
|
draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small)
|
||||||
|
draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small)
|
||||||
|
|
||||||
|
img.save(output_path)
|
||||||
|
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
# 文档内容块定义
|
||||||
|
def heading1(text):
|
||||||
|
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}}
|
||||||
|
|
||||||
|
def heading2(text):
|
||||||
|
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}}
|
||||||
|
|
||||||
|
def heading3(text):
|
||||||
|
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}}
|
||||||
|
|
||||||
|
def text_block(content):
|
||||||
|
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
|
||||||
|
|
||||||
|
def bullet(content):
|
||||||
|
return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}}
|
||||||
|
|
||||||
|
def ordered(content):
|
||||||
|
return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}}
|
||||||
|
|
||||||
|
def code_block(content, language=1):
|
||||||
|
return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}}
|
||||||
|
|
||||||
|
def divider():
|
||||||
|
return {"block_type": 22, "divider": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "#" * 60)
|
||||||
|
print("# 创建新文档:ai-proj 项目可见性手册")
|
||||||
|
print("#" * 60)
|
||||||
|
|
||||||
|
# Step 1: 生成示意图
|
||||||
|
print("\n--- Step 1: 生成示意图 ---")
|
||||||
|
diagram_path = generate_visibility_diagram()
|
||||||
|
ui_path = generate_ui_screenshot()
|
||||||
|
|
||||||
|
# Step 2: 创建新文档
|
||||||
|
print("\n--- Step 2: 创建新文档 ---")
|
||||||
|
doc_id = create_document("ai-proj 项目可见性手册")
|
||||||
|
set_document_permission(doc_id)
|
||||||
|
|
||||||
|
# Step 3: 创建手册内容
|
||||||
|
print("\n--- Step 3: 创建手册内容 ---")
|
||||||
|
|
||||||
|
# 第一部分:标题和概述
|
||||||
|
blocks_part1 = [
|
||||||
|
heading1("ai-proj 项目可见性手册"),
|
||||||
|
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||||||
|
divider(),
|
||||||
|
heading2("1. 功能概述"),
|
||||||
|
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||||||
|
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||||||
|
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||||||
|
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||||||
|
divider(),
|
||||||
|
heading2("2. 可见性对比"),
|
||||||
|
text_block("下图展示了两种可见性的区别:"),
|
||||||
|
]
|
||||||
|
create_blocks(doc_id, doc_id, blocks_part1)
|
||||||
|
print(" 第一部分内容创建完成")
|
||||||
|
|
||||||
|
# 插入可见性示意图
|
||||||
|
print("\n--- Step 4: 插入可见性示意图 ---")
|
||||||
|
insert_image(doc_id, diagram_path, "可见性示意图")
|
||||||
|
|
||||||
|
# 第二部分:设置方法
|
||||||
|
print("\n--- Step 5: 添加设置说明 ---")
|
||||||
|
blocks_part2 = [
|
||||||
|
divider(),
|
||||||
|
heading2("3. 设置项目可见性"),
|
||||||
|
heading3("3.1 创建项目时设置"),
|
||||||
|
text_block("在创建项目时,您可以选择项目的可见性:"),
|
||||||
|
ordered("点击「创建项目」按钮"),
|
||||||
|
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||||||
|
ordered("选择「私有项目」或「企业项目」"),
|
||||||
|
ordered("填写其他信息后点击「确定」"),
|
||||||
|
text_block("UI 界面示意:"),
|
||||||
|
]
|
||||||
|
create_blocks(doc_id, doc_id, blocks_part2)
|
||||||
|
print(" 设置说明创建完成")
|
||||||
|
|
||||||
|
# 插入 UI 示意图
|
||||||
|
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||||||
|
insert_image(doc_id, ui_path, "UI 示意图")
|
||||||
|
|
||||||
|
# 第三部分:修改方法和规则
|
||||||
|
print("\n--- Step 7: 添加剩余内容 ---")
|
||||||
|
blocks_part3 = [
|
||||||
|
heading3("3.2 修改已有项目"),
|
||||||
|
ordered("在项目列表中找到目标项目"),
|
||||||
|
ordered("点击项目卡片上的「编辑」按钮"),
|
||||||
|
ordered("在编辑页面修改「项目可见性」"),
|
||||||
|
ordered("点击「保存」"),
|
||||||
|
divider(),
|
||||||
|
heading2("4. 可见性规则"),
|
||||||
|
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||||||
|
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||||||
|
bullet("只有项目所有者或管理员可以修改可见性"),
|
||||||
|
bullet("设置为「企业项目」需要项目已关联企业"),
|
||||||
|
divider(),
|
||||||
|
heading2("5. API 参考"),
|
||||||
|
text_block("修改项目可见性的 API:"),
|
||||||
|
code_block('''PATCH /api/v1/projects/:id/visibility
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"visibility": "enterprise"
|
||||||
|
}
|
||||||
|
|
||||||
|
可选值: "private" | "enterprise"'''),
|
||||||
|
divider(),
|
||||||
|
heading2("6. 注意事项"),
|
||||||
|
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||||||
|
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||||||
|
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||||||
|
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||||||
|
divider(),
|
||||||
|
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||||||
|
]
|
||||||
|
create_blocks(doc_id, doc_id, blocks_part3)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("文档创建完成!")
|
||||||
|
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{doc_id}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 验证图片
|
||||||
|
print("\n--- 验证图片状态 ---")
|
||||||
|
time.sleep(2) # 等待服务器处理
|
||||||
|
blocks = get_document_blocks(doc_id)
|
||||||
|
image_count = 0
|
||||||
|
for block in blocks:
|
||||||
|
if block.get("block_type") == 27:
|
||||||
|
image_count += 1
|
||||||
|
image_data = block.get("image", {})
|
||||||
|
token = image_data.get("token", "")
|
||||||
|
width = image_data.get("width", 0)
|
||||||
|
height = image_data.get("height", 0)
|
||||||
|
status = "✅ 有效" if token else "❌ 空"
|
||||||
|
print(f"图片 #{image_count}: {status} (token: {token[:15]}..., 尺寸: {width}x{height})")
|
||||||
|
|
||||||
|
if image_count == 0:
|
||||||
|
print("⚠️ 警告:文档中没有图片块")
|
||||||
|
elif image_count == 2:
|
||||||
|
print(f"\n✅ 成功上传 {image_count} 张图片")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
239
plugins/feishu-plugin/debug_image.py
Normal file
239
plugins/feishu-plugin/debug_image.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
深入调试飞书云文档图片上传
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
_token = None
|
||||||
|
_token_expires = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
global _token, _token_expires
|
||||||
|
if _token and _token_expires and datetime.now() < _token_expires:
|
||||||
|
return _token
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": ZHIYUN_APP_ID,
|
||||||
|
"app_secret": ZHIYUN_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
_token = data["tenant_access_token"]
|
||||||
|
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
return _token
|
||||||
|
|
||||||
|
|
||||||
|
def headers():
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {get_token()}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_document_permission(document_id: str, editable: bool = True):
|
||||||
|
"""设置文档权限"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||||
|
payload = {
|
||||||
|
"external_access_entity": "open",
|
||||||
|
"security_entity": "anyone_can_view",
|
||||||
|
"comment_entity": "anyone_can_view",
|
||||||
|
"share_entity": "anyone",
|
||||||
|
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||||
|
"invite_external": False
|
||||||
|
}
|
||||||
|
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
|
||||||
|
return response.json().get("code") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_document(title: str, editable: bool = True):
|
||||||
|
"""创建文档(自动设置为组织内可编辑)"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents"
|
||||||
|
response = requests.post(url, headers=headers(), json={"title": title})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建文档失败: {data}")
|
||||||
|
doc = data["data"]["document"]
|
||||||
|
document_id = doc["document_id"]
|
||||||
|
print(f"[OK] 文档创建成功: {document_id}")
|
||||||
|
|
||||||
|
# 自动设置权限
|
||||||
|
if editable and set_document_permission(document_id, True):
|
||||||
|
print(f"[OK] 权限设置成功: 组织内可编辑")
|
||||||
|
|
||||||
|
return document_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_raw_content(document_id: str):
|
||||||
|
"""获取文档原始内容"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/raw_content"
|
||||||
|
response = requests.get(url, headers=headers())
|
||||||
|
data = response.json()
|
||||||
|
print(f"[DEBUG] raw_content 响应: {data}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_blocks(document_id: str):
|
||||||
|
"""获取文档块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||||
|
|
||||||
|
# 尝试添加 document_revision_id 参数
|
||||||
|
params = {"document_revision_id": -1} # -1 表示最新版本
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers(), params=params)
|
||||||
|
data = response.json()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def create_image_block(document_id: str):
|
||||||
|
"""创建图片块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
|
||||||
|
|
||||||
|
# 添加 document_revision_id 参数
|
||||||
|
params = {"document_revision_id": -1}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"children": [{
|
||||||
|
"block_type": 27,
|
||||||
|
"image": {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers(), params=params, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
print(f"[DEBUG] 创建图片块响应: {data}")
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建图片块失败: {data}")
|
||||||
|
|
||||||
|
block_id = data["data"]["children"][0]["block_id"]
|
||||||
|
print(f"[OK] 图片块创建成功: {block_id}")
|
||||||
|
return block_id
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(file_path: str, block_id: str):
|
||||||
|
"""上传图片"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, 'image/png')}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': 'docx_image',
|
||||||
|
'parent_node': block_id,
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {get_token()}"},
|
||||||
|
files=files,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
print(f"[DEBUG] 上传响应: {result}")
|
||||||
|
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise Exception(f"上传失败: {result}")
|
||||||
|
|
||||||
|
return result["data"]["file_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def bind_image_to_block(document_id: str, block_id: str, file_token: str):
|
||||||
|
"""
|
||||||
|
绑定图片到图片块 (关键步骤!)
|
||||||
|
|
||||||
|
使用 replace_image 字段通过 PATCH 请求绑定
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||||
|
|
||||||
|
params = {"document_revision_id": -1}
|
||||||
|
|
||||||
|
# 正确的 payload: 使用 replace_image
|
||||||
|
payload = {
|
||||||
|
"replace_image": {
|
||||||
|
"token": file_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"[INFO] 绑定图片: PATCH {url}")
|
||||||
|
print(f"[INFO] payload: {payload}")
|
||||||
|
|
||||||
|
response = requests.patch(url, headers=headers(), params=params, json=payload)
|
||||||
|
result = response.json()
|
||||||
|
print(f"[DEBUG] 绑定响应: {result}")
|
||||||
|
|
||||||
|
if result.get("code") == 0:
|
||||||
|
print(f"[OK] 图片绑定成功!")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 图片绑定失败: code={result.get('code')}, msg={result.get('msg')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "#" * 60)
|
||||||
|
print("# 深入调试飞书云文档图片上传")
|
||||||
|
print("#" * 60)
|
||||||
|
|
||||||
|
# 生成测试图片
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
test_image = "/tmp/debug_test_image.png"
|
||||||
|
img = Image.new('RGB', (200, 150), color='#4a90d9')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.text((100, 75), "Test", fill='white', anchor='mm')
|
||||||
|
img.save(test_image)
|
||||||
|
print(f"[OK] 测试图片: {test_image}")
|
||||||
|
|
||||||
|
# Step 1: 创建文档
|
||||||
|
print("\n--- Step 1: 创建文档 ---")
|
||||||
|
doc_id = create_document(f"调试图片上传 - {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
|
||||||
|
# Step 2: 创建图片块
|
||||||
|
print("\n--- Step 2: 创建图片块 ---")
|
||||||
|
block_id = create_image_block(doc_id)
|
||||||
|
|
||||||
|
# Step 3: 上传图片
|
||||||
|
print("\n--- Step 3: 上传图片 ---")
|
||||||
|
file_token = upload_image(test_image, block_id)
|
||||||
|
print(f"[OK] file_token: {file_token}")
|
||||||
|
|
||||||
|
# Step 4: 检查块状态
|
||||||
|
print("\n--- Step 4: 检查块状态 ---")
|
||||||
|
blocks = get_blocks(doc_id)
|
||||||
|
for item in blocks.get("data", {}).get("items", []):
|
||||||
|
if item.get("block_type") == 27:
|
||||||
|
print(f"[INFO] 图片块: {item}")
|
||||||
|
|
||||||
|
# Step 5: 绑定图片到图片块 (关键步骤!)
|
||||||
|
print("\n--- Step 5: 绑定图片到图片块 ---")
|
||||||
|
bind_image_to_block(doc_id, block_id, file_token)
|
||||||
|
|
||||||
|
# Step 6: 再次检查
|
||||||
|
print("\n--- Step 6: 再次检查块状态 ---")
|
||||||
|
import time
|
||||||
|
time.sleep(2) # 等待2秒
|
||||||
|
blocks = get_blocks(doc_id)
|
||||||
|
for item in blocks.get("data", {}).get("items", []):
|
||||||
|
if item.get("block_type") == 27:
|
||||||
|
print(f"[INFO] 图片块: {item}")
|
||||||
|
|
||||||
|
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
409
plugins/feishu-plugin/demo.py
Normal file
409
plugins/feishu-plugin/demo.py
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
飞书多维表格 Demo
|
||||||
|
使用 zhiyun.ai 凭证演示多维表格的完整操作流程
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
|
# ========== 配置 ==========
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 工具类 ==========
|
||||||
|
class FeishuBitable:
|
||||||
|
"""飞书多维表格操作工具类"""
|
||||||
|
|
||||||
|
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
|
||||||
|
self.app_id = app_id
|
||||||
|
self.app_secret = app_secret
|
||||||
|
self._token = None
|
||||||
|
self._token_expires = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self) -> str:
|
||||||
|
"""获取或刷新 access token"""
|
||||||
|
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": self.app_id,
|
||||||
|
"app_secret": self.app_secret
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
self._token = data["tenant_access_token"]
|
||||||
|
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
print(f"[OK] Token 获取成功,有效期至 {self._token_expires}")
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self) -> Dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========== 多维表格管理 ==========
|
||||||
|
def create_bitable(self, name: str, folder_token: str = None) -> Dict:
|
||||||
|
"""
|
||||||
|
创建新的多维表格
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 多维表格名称
|
||||||
|
folder_token: 文件夹 token(可选,不指定则创建在根目录)
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps"
|
||||||
|
payload = {"name": name}
|
||||||
|
if folder_token:
|
||||||
|
payload["folder_token"] = folder_token
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self.headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建多维表格失败: {data}")
|
||||||
|
|
||||||
|
app_info = data["data"]["app"]
|
||||||
|
print(f"[OK] 多维表格创建成功")
|
||||||
|
print(f" 名称: {app_info['name']}")
|
||||||
|
print(f" app_token: {app_info['app_token']}")
|
||||||
|
print(f" URL: {app_info.get('url', 'N/A')}")
|
||||||
|
return app_info
|
||||||
|
|
||||||
|
def list_tables(self, app_token: str) -> List[Dict]:
|
||||||
|
"""列出多维表格中的所有数据表"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
|
||||||
|
response = requests.get(url, headers=self.headers)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取数据表列表失败: {data}")
|
||||||
|
|
||||||
|
tables = data["data"].get("items", [])
|
||||||
|
print(f"[OK] 找到 {len(tables)} 个数据表")
|
||||||
|
for t in tables:
|
||||||
|
print(f" - {t['name']} (table_id: {t['table_id']})")
|
||||||
|
return tables
|
||||||
|
|
||||||
|
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
|
||||||
|
"""
|
||||||
|
在多维表格中创建数据表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_token: 多维表格 app_token
|
||||||
|
name: 数据表名称
|
||||||
|
fields: 字段定义列表
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
|
||||||
|
payload = {
|
||||||
|
"table": {
|
||||||
|
"name": name,
|
||||||
|
"default_view_name": "默认视图",
|
||||||
|
"fields": fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self.headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建数据表失败: {data}")
|
||||||
|
|
||||||
|
table_info = data["data"]
|
||||||
|
print(f"[OK] 数据表创建成功")
|
||||||
|
print(f" 名称: {name}")
|
||||||
|
print(f" table_id: {table_info['table_id']}")
|
||||||
|
return table_info
|
||||||
|
|
||||||
|
# ========== 记录操作 ==========
|
||||||
|
def list_records(self, app_token: str, table_id: str,
|
||||||
|
filter_str: str = None, page_size: int = 100) -> List[Dict]:
|
||||||
|
"""列出所有记录(自动分页)"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
all_records = []
|
||||||
|
page_token = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
params = {"page_size": min(page_size, 500)}
|
||||||
|
if page_token:
|
||||||
|
params["page_token"] = page_token
|
||||||
|
if filter_str:
|
||||||
|
params["filter"] = filter_str
|
||||||
|
|
||||||
|
response = requests.get(url, headers=self.headers, params=params)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"查询失败: {data}")
|
||||||
|
|
||||||
|
items = data["data"].get("items", [])
|
||||||
|
all_records.extend(items)
|
||||||
|
|
||||||
|
if not data["data"].get("has_more"):
|
||||||
|
break
|
||||||
|
page_token = data["data"]["page_token"]
|
||||||
|
|
||||||
|
return all_records
|
||||||
|
|
||||||
|
def create_record(self, app_token: str, table_id: str, fields: Dict) -> Dict:
|
||||||
|
"""创建单条记录"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
response = requests.post(url, headers=self.headers, json={"fields": fields})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建失败: {data}")
|
||||||
|
return data["data"]["record"]
|
||||||
|
|
||||||
|
def batch_create(self, app_token: str, table_id: str,
|
||||||
|
records: List[Dict], batch_size: int = 500) -> List[Dict]:
|
||||||
|
"""批量创建记录"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
|
||||||
|
created = []
|
||||||
|
|
||||||
|
for i in range(0, len(records), batch_size):
|
||||||
|
batch = [{"fields": r} if "fields" not in r else r for r in records[i:i+batch_size]]
|
||||||
|
response = requests.post(url, headers=self.headers, json={"records": batch})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"批量创建失败: {data}")
|
||||||
|
created.extend(data["data"]["records"])
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
|
||||||
|
"""获取字段定义"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||||
|
response = requests.get(url, headers=self.headers)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取字段失败: {data}")
|
||||||
|
return data["data"]["items"]
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Demo 函数 ==========
|
||||||
|
def demo_verify_credentials():
|
||||||
|
"""验证凭证是否有效"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Step 1: 验证飞书应用凭证")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
bitable = FeishuBitable()
|
||||||
|
token = bitable.token # 触发 token 获取
|
||||||
|
print(f" Token 前缀: {token[:20]}...")
|
||||||
|
return bitable
|
||||||
|
|
||||||
|
|
||||||
|
def demo_create_bitable(bitable: FeishuBitable) -> str:
|
||||||
|
"""创建新的多维表格"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Step 2: 创建多维表格")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
name = f"Claude Code Demo - {timestamp}"
|
||||||
|
|
||||||
|
app_info = bitable.create_bitable(name)
|
||||||
|
return app_info["app_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def demo_create_task_table(bitable: FeishuBitable, app_token: str) -> str:
|
||||||
|
"""创建任务管理数据表"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Step 3: 创建任务管理数据表")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
# 定义字段
|
||||||
|
fields = [
|
||||||
|
{
|
||||||
|
"field_name": "任务名称",
|
||||||
|
"type": 1 # 文本
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_name": "状态",
|
||||||
|
"type": 3, # 单选
|
||||||
|
"property": {
|
||||||
|
"options": [
|
||||||
|
{"name": "待处理", "color": 0},
|
||||||
|
{"name": "进行中", "color": 1},
|
||||||
|
{"name": "已完成", "color": 2},
|
||||||
|
{"name": "已取消", "color": 3}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_name": "优先级",
|
||||||
|
"type": 3, # 单选
|
||||||
|
"property": {
|
||||||
|
"options": [
|
||||||
|
{"name": "高", "color": 4},
|
||||||
|
{"name": "中", "color": 5},
|
||||||
|
{"name": "低", "color": 6}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_name": "负责人",
|
||||||
|
"type": 1 # 文本
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_name": "截止日期",
|
||||||
|
"type": 5, # 日期
|
||||||
|
"property": {
|
||||||
|
"date_formatter": "yyyy/MM/dd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_name": "工时(小时)",
|
||||||
|
"type": 2 # 数字
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field_name": "备注",
|
||||||
|
"type": 1 # 文本
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
table_info = bitable.create_table(app_token, "任务看板", fields)
|
||||||
|
return table_info["table_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def demo_add_sample_data(bitable: FeishuBitable, app_token: str, table_id: str):
|
||||||
|
"""添加示例数据"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Step 4: 添加示例数据")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
# 准备示例数据
|
||||||
|
now = datetime.now()
|
||||||
|
sample_tasks = [
|
||||||
|
{
|
||||||
|
"任务名称": "完成产品需求文档",
|
||||||
|
"状态": "已完成",
|
||||||
|
"优先级": "高",
|
||||||
|
"负责人": "张三",
|
||||||
|
"截止日期": int((now - timedelta(days=2)).timestamp() * 1000),
|
||||||
|
"工时(小时)": 8,
|
||||||
|
"备注": "PRD 已评审通过"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"任务名称": "设计系统架构方案",
|
||||||
|
"状态": "进行中",
|
||||||
|
"优先级": "高",
|
||||||
|
"负责人": "李四",
|
||||||
|
"截止日期": int((now + timedelta(days=3)).timestamp() * 1000),
|
||||||
|
"工时(小时)": 16,
|
||||||
|
"备注": "正在编写技术方案"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"任务名称": "开发用户登录模块",
|
||||||
|
"状态": "待处理",
|
||||||
|
"优先级": "中",
|
||||||
|
"负责人": "王五",
|
||||||
|
"截止日期": int((now + timedelta(days=7)).timestamp() * 1000),
|
||||||
|
"工时(小时)": 24,
|
||||||
|
"备注": "等待架构方案确定"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"任务名称": "编写单元测试",
|
||||||
|
"状态": "待处理",
|
||||||
|
"优先级": "中",
|
||||||
|
"负责人": "赵六",
|
||||||
|
"截止日期": int((now + timedelta(days=10)).timestamp() * 1000),
|
||||||
|
"工时(小时)": 12,
|
||||||
|
"备注": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"任务名称": "部署测试环境",
|
||||||
|
"状态": "待处理",
|
||||||
|
"优先级": "低",
|
||||||
|
"负责人": "钱七",
|
||||||
|
"截止日期": int((now + timedelta(days=14)).timestamp() * 1000),
|
||||||
|
"工时(小时)": 4,
|
||||||
|
"备注": "需要申请服务器资源"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 批量创建记录
|
||||||
|
created = bitable.batch_create(app_token, table_id, sample_tasks)
|
||||||
|
print(f"[OK] 成功创建 {len(created)} 条示例记录")
|
||||||
|
|
||||||
|
# 显示创建的记录
|
||||||
|
for i, record in enumerate(created, 1):
|
||||||
|
fields = record["fields"]
|
||||||
|
print(f" {i}. {fields.get('任务名称')} - {fields.get('状态')} ({fields.get('优先级')}优先级)")
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def demo_query_data(bitable: FeishuBitable, app_token: str, table_id: str):
|
||||||
|
"""查询数据演示"""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Step 5: 查询数据演示")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
# 查询所有记录
|
||||||
|
all_records = bitable.list_records(app_token, table_id)
|
||||||
|
print(f"[OK] 共 {len(all_records)} 条记录")
|
||||||
|
|
||||||
|
# 获取字段定义
|
||||||
|
fields = bitable.get_fields(app_token, table_id)
|
||||||
|
print(f"[OK] 共 {len(fields)} 个字段:")
|
||||||
|
for f in fields:
|
||||||
|
print(f" - {f['field_name']} (类型: {f['type']})")
|
||||||
|
|
||||||
|
return all_records
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""运行完整 Demo"""
|
||||||
|
print("\n" + "#"*60)
|
||||||
|
print("# 飞书多维表格 Demo - zhiyun.ai")
|
||||||
|
print("#"*60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: 验证凭证
|
||||||
|
bitable = demo_verify_credentials()
|
||||||
|
|
||||||
|
# Step 2: 创建多维表格
|
||||||
|
app_token = demo_create_bitable(bitable)
|
||||||
|
|
||||||
|
# Step 3: 创建数据表
|
||||||
|
table_id = demo_create_task_table(bitable, app_token)
|
||||||
|
|
||||||
|
# Step 4: 添加示例数据
|
||||||
|
demo_add_sample_data(bitable, app_token, table_id)
|
||||||
|
|
||||||
|
# Step 5: 查询数据
|
||||||
|
demo_query_data(bitable, app_token, table_id)
|
||||||
|
|
||||||
|
# 完成
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Demo 完成!")
|
||||||
|
print("="*60)
|
||||||
|
print(f"\n多维表格信息:")
|
||||||
|
print(f" app_token: {app_token}")
|
||||||
|
print(f" table_id: {table_id}")
|
||||||
|
print(f"\n访问地址:")
|
||||||
|
print(f" https://zhiyun-ai.feishu.cn/base/{app_token}?table={table_id}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return app_token, table_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[ERROR] {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
451
plugins/feishu-plugin/feishu_docx.py
Normal file
451
plugins/feishu-plugin/feishu_docx.py
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
飞书云文档操作工具类
|
||||||
|
包含文档创建、内容块管理、图片上传等功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuDocx:
|
||||||
|
"""飞书云文档操作工具类(含图片上传)"""
|
||||||
|
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
DEFAULT_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
DEFAULT_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
|
||||||
|
def __init__(self, app_id: str = None, app_secret: str = None):
|
||||||
|
self.app_id = app_id or os.environ.get("FEISHU_APP_ID") or self.DEFAULT_APP_ID
|
||||||
|
self.app_secret = app_secret or os.environ.get("FEISHU_APP_SECRET") or self.DEFAULT_APP_SECRET
|
||||||
|
self._token = None
|
||||||
|
self._token_expires = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self) -> str:
|
||||||
|
"""获取或刷新 access token"""
|
||||||
|
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
url = f"{self.BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": self.app_id,
|
||||||
|
"app_secret": self.app_secret
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
self._token = data["tenant_access_token"]
|
||||||
|
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self) -> dict:
|
||||||
|
"""获取请求头"""
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==================== 文档管理 ====================
|
||||||
|
|
||||||
|
def set_document_permission(self, document_id: str, editable: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
设置文档权限
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: 文档ID
|
||||||
|
editable: True=组织内可编辑, False=组织内只读
|
||||||
|
"""
|
||||||
|
url = f"{self.BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||||
|
payload = {
|
||||||
|
"external_access_entity": "open",
|
||||||
|
"security_entity": "anyone_can_view",
|
||||||
|
"comment_entity": "anyone_can_view",
|
||||||
|
"share_entity": "anyone",
|
||||||
|
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||||
|
"invite_external": False
|
||||||
|
}
|
||||||
|
response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload)
|
||||||
|
return response.json().get("code") == 0
|
||||||
|
|
||||||
|
def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict:
|
||||||
|
"""
|
||||||
|
创建文档(自动设置为组织内可编辑)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: 文档标题
|
||||||
|
folder_token: 文件夹token (可选)
|
||||||
|
editable: 是否设置为组织内可编辑 (默认True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"document_id": "xxx", "title": "xxx", "url": "xxx"}
|
||||||
|
"""
|
||||||
|
url = f"{self.BASE_URL}/docx/v1/documents"
|
||||||
|
payload = {"title": title}
|
||||||
|
if folder_token:
|
||||||
|
payload["folder_token"] = folder_token
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self.headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建文档失败: {data}")
|
||||||
|
|
||||||
|
doc = data["data"]["document"]
|
||||||
|
document_id = doc["document_id"]
|
||||||
|
|
||||||
|
if editable:
|
||||||
|
self.set_document_permission(document_id, editable=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"document_id": document_id,
|
||||||
|
"title": doc["title"],
|
||||||
|
"url": f"https://feishu.cn/docx/{document_id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_document_blocks(self, document_id: str, page_size: int = 500) -> List[Dict]:
|
||||||
|
"""获取文档所有内容块"""
|
||||||
|
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||||
|
all_blocks = []
|
||||||
|
page_token = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
params = {"page_size": page_size}
|
||||||
|
if page_token:
|
||||||
|
params["page_token"] = page_token
|
||||||
|
|
||||||
|
response = requests.get(url, headers=self.headers, params=params)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取内容块失败: {data}")
|
||||||
|
|
||||||
|
all_blocks.extend(data["data"].get("items", []))
|
||||||
|
|
||||||
|
if not data["data"].get("has_more"):
|
||||||
|
break
|
||||||
|
page_token = data["data"]["page_token"]
|
||||||
|
|
||||||
|
return all_blocks
|
||||||
|
|
||||||
|
# ==================== 内容块操作 ====================
|
||||||
|
|
||||||
|
def create_blocks(self, document_id: str, blocks: list, parent_id: str = None) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
创建内容块
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: 文档ID
|
||||||
|
blocks: 内容块列表
|
||||||
|
parent_id: 父块ID (默认为文档根块)
|
||||||
|
"""
|
||||||
|
parent_id = parent_id or document_id
|
||||||
|
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=self.headers,
|
||||||
|
params={"document_revision_id": -1},
|
||||||
|
json={"children": blocks}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建内容块失败: {data}")
|
||||||
|
|
||||||
|
return data["data"]["children"]
|
||||||
|
|
||||||
|
# ==================== 图片上传 (三步流程) ====================
|
||||||
|
|
||||||
|
def create_empty_image_block(self, document_id: str, parent_id: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Step 1: 创建空图片块
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: 文档ID
|
||||||
|
parent_id: 父块ID (默认为文档根块)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
block_id: 图片块ID
|
||||||
|
"""
|
||||||
|
parent_id = parent_id or document_id
|
||||||
|
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"children": [{
|
||||||
|
"block_type": 27,
|
||||||
|
"image": {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=self.headers,
|
||||||
|
params={"document_revision_id": -1},
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建图片块失败: {data}")
|
||||||
|
|
||||||
|
return data["data"]["children"][0]["block_id"]
|
||||||
|
|
||||||
|
def upload_image_to_block(self, file_path: str, block_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Step 2: 上传图片文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 本地图片路径
|
||||||
|
block_id: 图片块ID (从 Step 1 获取)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
file_token: 图片token
|
||||||
|
"""
|
||||||
|
url = f"{self.BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
# 根据文件扩展名确定 MIME 类型
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
mime_types = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
}
|
||||||
|
mime_type = mime_types.get(ext, 'image/png')
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, mime_type)}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': 'docx_image',
|
||||||
|
'parent_node': block_id,
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {self.token}"},
|
||||||
|
files=files,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise Exception(f"上传图片失败: {result}")
|
||||||
|
|
||||||
|
return result["data"]["file_token"]
|
||||||
|
|
||||||
|
def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> bool:
|
||||||
|
"""
|
||||||
|
Step 3: 绑定图片到图片块 (关键步骤!)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: 文档ID
|
||||||
|
block_id: 图片块ID
|
||||||
|
file_token: 图片token (从 Step 2 获取)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功
|
||||||
|
"""
|
||||||
|
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"replace_image": {
|
||||||
|
"token": file_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.patch(
|
||||||
|
url,
|
||||||
|
headers=self.headers,
|
||||||
|
params={"document_revision_id": -1},
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"绑定图片失败: {data}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def insert_image(self, document_id: str, file_path: str, parent_id: str = None) -> dict:
|
||||||
|
"""
|
||||||
|
插入图片到文档 (封装三步流程)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: 文档ID
|
||||||
|
file_path: 本地图片路径
|
||||||
|
parent_id: 父块ID (默认为文档根块)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"block_id": "xxx", "file_token": "xxx"}
|
||||||
|
"""
|
||||||
|
# Step 1: 创建空图片块
|
||||||
|
block_id = self.create_empty_image_block(document_id, parent_id)
|
||||||
|
|
||||||
|
# Step 2: 上传图片
|
||||||
|
file_token = self.upload_image_to_block(file_path, block_id)
|
||||||
|
|
||||||
|
# Step 3: 绑定图片
|
||||||
|
self.bind_image_to_block(document_id, block_id, file_token)
|
||||||
|
|
||||||
|
return {"block_id": block_id, "file_token": file_token}
|
||||||
|
|
||||||
|
def check_image_status(self, document_id: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
检查文档中图片块的状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Dict]: 图片块状态列表
|
||||||
|
"""
|
||||||
|
blocks = self.get_document_blocks(document_id)
|
||||||
|
images = []
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
if block.get("block_type") == 27:
|
||||||
|
image_data = block.get("image", {})
|
||||||
|
images.append({
|
||||||
|
"block_id": block.get("block_id"),
|
||||||
|
"token": image_data.get("token", ""),
|
||||||
|
"width": image_data.get("width", 0),
|
||||||
|
"height": image_data.get("height", 0),
|
||||||
|
"is_valid": bool(image_data.get("token"))
|
||||||
|
})
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
# ==================== 内容块构建器 ====================
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def heading1(text: str) -> dict:
|
||||||
|
"""一级标题"""
|
||||||
|
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def heading2(text: str) -> dict:
|
||||||
|
"""二级标题"""
|
||||||
|
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def heading3(text: str) -> dict:
|
||||||
|
"""三级标题"""
|
||||||
|
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def text(content: str) -> dict:
|
||||||
|
"""文本段落"""
|
||||||
|
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def bullet(content: str) -> dict:
|
||||||
|
"""无序列表项"""
|
||||||
|
return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ordered(content: str) -> dict:
|
||||||
|
"""有序列表项"""
|
||||||
|
return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def code(content: str, language: int = 1) -> dict:
|
||||||
|
"""代码块 (language: 1=JSON, 4=Python, 等)"""
|
||||||
|
return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def divider() -> dict:
|
||||||
|
"""分割线"""
|
||||||
|
return {"block_type": 22, "divider": {}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def todo(content: str, done: bool = False) -> dict:
|
||||||
|
"""待办事项"""
|
||||||
|
return {"block_type": 17, "todo": {"elements": [{"text_run": {"content": content}}], "style": {"done": done}}}
|
||||||
|
|
||||||
|
# ==================== 高级功能 ====================
|
||||||
|
|
||||||
|
def create_meeting_minutes(self, title: str, content: dict) -> dict:
|
||||||
|
"""
|
||||||
|
创建会议纪要
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: 文档标题
|
||||||
|
content: {
|
||||||
|
"summary": "会议摘要",
|
||||||
|
"points": [{"title": "...", "items": ["..."]}],
|
||||||
|
"todos": [{"assignee": "...", "task": "..."}]
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 文档信息
|
||||||
|
"""
|
||||||
|
doc = self.create_document(title)
|
||||||
|
document_id = doc["document_id"]
|
||||||
|
|
||||||
|
blocks = []
|
||||||
|
|
||||||
|
if content.get("summary"):
|
||||||
|
blocks.append(self.heading2("会议摘要"))
|
||||||
|
blocks.append(self.text(content["summary"]))
|
||||||
|
blocks.append(self.divider())
|
||||||
|
|
||||||
|
if content.get("points"):
|
||||||
|
blocks.append(self.heading2("小结"))
|
||||||
|
for point in content["points"]:
|
||||||
|
blocks.append(self.heading3(point["title"]))
|
||||||
|
for item in point.get("items", []):
|
||||||
|
blocks.append(self.bullet(item))
|
||||||
|
blocks.append(self.divider())
|
||||||
|
|
||||||
|
if content.get("todos"):
|
||||||
|
blocks.append(self.heading2("待办事项"))
|
||||||
|
for todo_item in content["todos"]:
|
||||||
|
text = f"{todo_item['assignee']}: {todo_item['task']}"
|
||||||
|
blocks.append(self.todo(text))
|
||||||
|
|
||||||
|
self.create_blocks(document_id, blocks)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 使用示例 ====================
|
||||||
|
if __name__ == "__main__":
|
||||||
|
docx = FeishuDocx()
|
||||||
|
|
||||||
|
# 示例1: 创建文档并插入图片
|
||||||
|
print("=== 示例1: 创建带图片的文档 ===")
|
||||||
|
doc = docx.create_document("图片上传测试文档")
|
||||||
|
print(f"文档创建成功: {doc['url']}")
|
||||||
|
|
||||||
|
# 如果有测试图片,取消注释以下代码
|
||||||
|
# result = docx.insert_image(doc["document_id"], "/path/to/image.png")
|
||||||
|
# print(f"图片插入成功: {result}")
|
||||||
|
|
||||||
|
# 示例2: 创建会议纪要
|
||||||
|
print("\n=== 示例2: 创建会议纪要 ===")
|
||||||
|
doc = docx.create_meeting_minutes(
|
||||||
|
"测试会议纪要",
|
||||||
|
{
|
||||||
|
"summary": "本次会议讨论了项目进度...",
|
||||||
|
"points": [{"title": "进度汇报", "items": ["任务A已完成", "任务B进行中"]}],
|
||||||
|
"todos": [{"assignee": "张三", "task": "完成文档编写"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f"会议纪要已创建: {doc['url']}")
|
||||||
|
|
||||||
|
# 示例3: 检查图片状态
|
||||||
|
print("\n=== 示例3: 检查图片状态 ===")
|
||||||
|
images = docx.check_image_status(doc["document_id"])
|
||||||
|
if images:
|
||||||
|
for img in images:
|
||||||
|
status = "✅ 有效" if img["is_valid"] else "❌ 空"
|
||||||
|
print(f"图片: {status}, 尺寸: {img['width']}x{img['height']}")
|
||||||
|
else:
|
||||||
|
print("文档中没有图片")
|
||||||
308
plugins/feishu-plugin/migrate_wps.py
Normal file
308
plugins/feishu-plugin/migrate_wps.py
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
WPS .dbt 文件迁移到飞书多维表格
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
# ========== 配置 ==========
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
# 源文件
|
||||||
|
SOURCE_FILE = "/Users/donglinlai/Downloads/酷采团购系统优化进度表.dbt.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuBitable:
|
||||||
|
"""飞书多维表格操作工具类"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._token = None
|
||||||
|
self._token_expires = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self) -> str:
|
||||||
|
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": ZHIYUN_APP_ID,
|
||||||
|
"app_secret": ZHIYUN_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
self._token = data["tenant_access_token"]
|
||||||
|
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self):
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_bitable(self, name: str) -> Dict:
|
||||||
|
"""创建多维表格"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps"
|
||||||
|
response = requests.post(url, headers=self.headers, json={"name": name})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建多维表格失败: {data}")
|
||||||
|
|
||||||
|
return data["data"]["app"]
|
||||||
|
|
||||||
|
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
|
||||||
|
"""创建数据表"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
|
||||||
|
payload = {
|
||||||
|
"table": {
|
||||||
|
"name": name,
|
||||||
|
"default_view_name": "默认视图",
|
||||||
|
"fields": fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=self.headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建数据表失败: {data}")
|
||||||
|
|
||||||
|
return data["data"]
|
||||||
|
|
||||||
|
def batch_create_records(self, app_token: str, table_id: str,
|
||||||
|
records: List[Dict], batch_size: int = 100) -> int:
|
||||||
|
"""批量创建记录"""
|
||||||
|
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
|
||||||
|
total_created = 0
|
||||||
|
|
||||||
|
for i in range(0, len(records), batch_size):
|
||||||
|
batch = records[i:i+batch_size]
|
||||||
|
payload = {"records": [{"fields": r} for r in batch]}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self.headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
print(f" [WARN] 批次 {i//batch_size + 1} 部分失败: {data.get('msg', '')}")
|
||||||
|
# 尝试逐条插入
|
||||||
|
for record in batch:
|
||||||
|
try:
|
||||||
|
single_url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
single_resp = requests.post(single_url, headers=self.headers, json={"fields": record})
|
||||||
|
if single_resp.json().get("code") == 0:
|
||||||
|
total_created += 1
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
total_created += len(data["data"]["records"])
|
||||||
|
|
||||||
|
return total_created
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_column_type(series: pd.Series, col_name: str) -> Dict:
|
||||||
|
"""分析列的数据类型,返回飞书字段定义"""
|
||||||
|
col_lower = col_name.lower()
|
||||||
|
|
||||||
|
# 根据列名判断类型
|
||||||
|
if any(kw in col_lower for kw in ['日期', '时间', 'date', 'time', '提出时间', '发版日期', '更新时间']):
|
||||||
|
return {"field_name": col_name, "type": 5, "property": {"date_formatter": "yyyy/MM/dd"}}
|
||||||
|
|
||||||
|
if any(kw in col_lower for kw in ['图片', '附件', '截图', 'image', 'file', 'attachment']):
|
||||||
|
return {"field_name": col_name, "type": 1} # 作为文本处理
|
||||||
|
|
||||||
|
if any(kw in col_lower for kw in ['优先级', '状态', '类型', '分类', '终端', '严重程度']):
|
||||||
|
# 提取唯一值作为选项
|
||||||
|
unique_vals = series.dropna().astype(str).unique()
|
||||||
|
unique_vals = [v for v in unique_vals if v and v != 'nan' and len(v) < 50][:20]
|
||||||
|
if len(unique_vals) > 0 and len(unique_vals) <= 20:
|
||||||
|
return {
|
||||||
|
"field_name": col_name,
|
||||||
|
"type": 3, # 单选
|
||||||
|
"property": {
|
||||||
|
"options": [{"name": str(v)} for v in unique_vals]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(kw in col_lower for kw in ['进度', '百分比', '%']):
|
||||||
|
return {"field_name": col_name, "type": 2} # 数字
|
||||||
|
|
||||||
|
# 检查是否为数字列
|
||||||
|
try:
|
||||||
|
numeric_vals = pd.to_numeric(series.dropna(), errors='coerce')
|
||||||
|
if numeric_vals.notna().sum() / max(len(series.dropna()), 1) > 0.8:
|
||||||
|
return {"field_name": col_name, "type": 2} # 数字
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 默认为文本
|
||||||
|
return {"field_name": col_name, "type": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_value(val: Any, field_type: int) -> Any:
|
||||||
|
"""清理和转换值"""
|
||||||
|
if pd.isna(val) or val is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if field_type == 5: # 日期
|
||||||
|
try:
|
||||||
|
if isinstance(val, (datetime, pd.Timestamp)):
|
||||||
|
return int(val.timestamp() * 1000)
|
||||||
|
elif isinstance(val, str):
|
||||||
|
dt = pd.to_datetime(val)
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if field_type == 2: # 数字
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if field_type == 3: # 单选
|
||||||
|
val_str = str(val).strip()
|
||||||
|
if val_str and val_str != 'nan':
|
||||||
|
return val_str
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 文本类型
|
||||||
|
val_str = str(val).strip()
|
||||||
|
if val_str == 'nan' or not val_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 限制文本长度
|
||||||
|
if len(val_str) > 10000:
|
||||||
|
val_str = val_str[:10000] + "..."
|
||||||
|
|
||||||
|
return val_str
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_sheet(bitable: FeishuBitable, app_token: str,
|
||||||
|
df: pd.DataFrame, sheet_name: str) -> str:
|
||||||
|
"""迁移单个 Sheet 到数据表"""
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"迁移 Sheet: 【{sheet_name}】")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
|
||||||
|
# 清理列名
|
||||||
|
df.columns = [str(c).strip() for c in df.columns]
|
||||||
|
|
||||||
|
# 去除完全空的行
|
||||||
|
df = df.dropna(how='all')
|
||||||
|
|
||||||
|
print(f" 数据: {len(df)} 行, {len(df.columns)} 列")
|
||||||
|
|
||||||
|
# 分析字段类型
|
||||||
|
fields = []
|
||||||
|
field_types = {}
|
||||||
|
for col in df.columns:
|
||||||
|
if not col or col.startswith('Unnamed'):
|
||||||
|
continue
|
||||||
|
field_def = analyze_column_type(df[col], col)
|
||||||
|
fields.append(field_def)
|
||||||
|
field_types[col] = field_def["type"]
|
||||||
|
|
||||||
|
print(f" 字段: {len(fields)} 个")
|
||||||
|
|
||||||
|
# 创建数据表
|
||||||
|
table_info = bitable.create_table(app_token, sheet_name, fields)
|
||||||
|
table_id = table_info["table_id"]
|
||||||
|
print(f" [OK] 数据表创建成功: {table_id}")
|
||||||
|
|
||||||
|
# 准备记录数据
|
||||||
|
records = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
record = {}
|
||||||
|
for col in df.columns:
|
||||||
|
if not col or col.startswith('Unnamed'):
|
||||||
|
continue
|
||||||
|
val = clean_value(row[col], field_types.get(col, 1))
|
||||||
|
if val is not None:
|
||||||
|
record[col] = val
|
||||||
|
if record: # 只添加非空记录
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
print(f" 准备导入 {len(records)} 条记录...")
|
||||||
|
|
||||||
|
# 批量创建记录
|
||||||
|
if records:
|
||||||
|
created = bitable.batch_create_records(app_token, table_id, records)
|
||||||
|
print(f" [OK] 成功导入 {created}/{len(records)} 条记录")
|
||||||
|
else:
|
||||||
|
print(f" [INFO] 无有效数据")
|
||||||
|
|
||||||
|
return table_id
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "#" * 60)
|
||||||
|
print("# WPS 文件迁移到飞书多维表格")
|
||||||
|
print("#" * 60)
|
||||||
|
|
||||||
|
bitable = FeishuBitable()
|
||||||
|
|
||||||
|
# Step 1: 读取源文件
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 1: 读取源文件")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
xlsx = pd.ExcelFile(SOURCE_FILE)
|
||||||
|
print(f" 文件: {SOURCE_FILE}")
|
||||||
|
print(f" Sheet 数量: {len(xlsx.sheet_names)}")
|
||||||
|
|
||||||
|
# Step 2: 创建多维表格
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 2: 创建飞书多维表格")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
||||||
|
bitable_name = f"酷采团购系统优化进度表 (迁移 {timestamp})"
|
||||||
|
|
||||||
|
app_info = bitable.create_bitable(bitable_name)
|
||||||
|
app_token = app_info["app_token"]
|
||||||
|
print(f" [OK] 多维表格创建成功")
|
||||||
|
print(f" 名称: {bitable_name}")
|
||||||
|
print(f" app_token: {app_token}")
|
||||||
|
|
||||||
|
# Step 3: 迁移每个 Sheet
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 3: 迁移数据表")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
table_ids = {}
|
||||||
|
for sheet_name in xlsx.sheet_names:
|
||||||
|
df = pd.read_excel(xlsx, sheet_name=sheet_name)
|
||||||
|
table_id = migrate_sheet(bitable, app_token, df, sheet_name)
|
||||||
|
table_ids[sheet_name] = table_id
|
||||||
|
|
||||||
|
# 完成
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("迁移完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"\n多维表格信息:")
|
||||||
|
print(f" 名称: {bitable_name}")
|
||||||
|
print(f" app_token: {app_token}")
|
||||||
|
print(f"\n数据表:")
|
||||||
|
for name, tid in table_ids.items():
|
||||||
|
print(f" - {name}: {tid}")
|
||||||
|
print(f"\n访问地址:")
|
||||||
|
print(f" https://zhiyuncai.feishu.cn/base/{app_token}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return app_token, table_ids
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
449
plugins/feishu-plugin/rebuild_visibility_manual.py
Normal file
449
plugins/feishu-plugin/rebuild_visibility_manual.py
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
重建飞书文档:ai-proj 项目可见性手册
|
||||||
|
清理现有内容后重新生成
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
# 目标文档
|
||||||
|
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||||||
|
|
||||||
|
_token = None
|
||||||
|
_token_expires = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
global _token, _token_expires
|
||||||
|
if _token and _token_expires and datetime.now() < _token_expires:
|
||||||
|
return _token
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": ZHIYUN_APP_ID,
|
||||||
|
"app_secret": ZHIYUN_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
_token = data["tenant_access_token"]
|
||||||
|
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
return _token
|
||||||
|
|
||||||
|
|
||||||
|
def headers():
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {get_token()}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_blocks(document_id: str):
|
||||||
|
"""获取文档所有块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||||
|
response = requests.get(url, headers=headers())
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取块失败: {data}")
|
||||||
|
return data["data"].get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_block(document_id: str, block_id: str):
|
||||||
|
"""删除块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||||
|
response = requests.delete(url, headers=headers(), params={"document_revision_id": -1})
|
||||||
|
result = response.json()
|
||||||
|
return result.get("code") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def clear_document(document_id: str):
|
||||||
|
"""清空文档内容(保留根块)"""
|
||||||
|
print("正在清空文档...")
|
||||||
|
blocks = get_document_blocks(document_id)
|
||||||
|
|
||||||
|
# 找出所有子块(排除根块本身)
|
||||||
|
child_blocks = [b for b in blocks if b.get("block_id") != document_id and b.get("parent_id") == document_id]
|
||||||
|
|
||||||
|
print(f" 发现 {len(child_blocks)} 个顶级子块需要删除")
|
||||||
|
|
||||||
|
# 从后向前删除,避免索引问题
|
||||||
|
for block in reversed(child_blocks):
|
||||||
|
block_id = block.get("block_id")
|
||||||
|
if delete_block(document_id, block_id):
|
||||||
|
print(f" 删除: {block_id[:20]}...")
|
||||||
|
else:
|
||||||
|
print(f" 删除失败: {block_id[:20]}...")
|
||||||
|
time.sleep(0.1) # 避免请求过快
|
||||||
|
|
||||||
|
print(" 文档清空完成")
|
||||||
|
|
||||||
|
|
||||||
|
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||||||
|
"""创建内容块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||||
|
payload = {"children": blocks}
|
||||||
|
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建块失败: {data}")
|
||||||
|
return data["data"].get("children", [])
|
||||||
|
|
||||||
|
|
||||||
|
def create_image_block(document_id: str, parent_id: str):
|
||||||
|
"""创建空图片块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||||
|
payload = {
|
||||||
|
"children": [{
|
||||||
|
"block_type": 27,
|
||||||
|
"image": {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建图片块失败: {data}")
|
||||||
|
return data["data"]["children"][0]["block_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(file_path: str, block_id: str):
|
||||||
|
"""上传图片"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, 'image/png')}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': 'docx_image',
|
||||||
|
'parent_node': block_id,
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {get_token()}"},
|
||||||
|
files=files,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise Exception(f"上传失败: {result}")
|
||||||
|
return result["data"]["file_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||||
|
"""绑定图片到图片块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||||
|
payload = {"replace_image": {"token": file_token}}
|
||||||
|
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"绑定失败: {data}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def insert_image(document_id: str, file_path: str, description: str = ""):
|
||||||
|
"""插入图片的完整流程"""
|
||||||
|
print(f" 插入图片: {description}")
|
||||||
|
|
||||||
|
# Step 1: 创建空图片块
|
||||||
|
block_id = create_image_block(document_id, document_id)
|
||||||
|
print(f" block_id: {block_id[:20]}...")
|
||||||
|
|
||||||
|
# Step 2: 上传图片
|
||||||
|
file_token = upload_image(file_path, block_id)
|
||||||
|
print(f" file_token: {file_token[:20]}...")
|
||||||
|
|
||||||
|
# Step 3: 绑定图片
|
||||||
|
bind_image(document_id, block_id, file_token)
|
||||||
|
print(f" 绑定成功!")
|
||||||
|
|
||||||
|
return block_id
|
||||||
|
|
||||||
|
|
||||||
|
def generate_visibility_diagram():
|
||||||
|
"""生成可见性示意图"""
|
||||||
|
output_path = "/tmp/visibility_diagram.png"
|
||||||
|
|
||||||
|
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 尝试加载字体,如果失败则使用默认
|
||||||
|
try:
|
||||||
|
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||||||
|
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18)
|
||||||
|
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14)
|
||||||
|
except:
|
||||||
|
font_large = ImageFont.load_default()
|
||||||
|
font_medium = ImageFont.load_default()
|
||||||
|
font_small = ImageFont.load_default()
|
||||||
|
|
||||||
|
# 标题背景
|
||||||
|
draw.rectangle([0, 0, 800, 60], fill='#1890ff')
|
||||||
|
draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large)
|
||||||
|
|
||||||
|
# 私有项目区域
|
||||||
|
draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3)
|
||||||
|
draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium)
|
||||||
|
draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 私有项目说明
|
||||||
|
draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 私有项目图标
|
||||||
|
draw.ellipse([175, 210, 245, 280], fill='#fa8c16')
|
||||||
|
draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large)
|
||||||
|
draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small)
|
||||||
|
draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 企业项目区域
|
||||||
|
draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3)
|
||||||
|
draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium)
|
||||||
|
draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 企业项目说明
|
||||||
|
draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 企业项目图标组
|
||||||
|
positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)]
|
||||||
|
for x, y in positions:
|
||||||
|
draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff')
|
||||||
|
draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium)
|
||||||
|
|
||||||
|
draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 中间箭头
|
||||||
|
draw.text((400, 225), "→", fill='#333333', anchor='mm', font=font_large)
|
||||||
|
|
||||||
|
img.save(output_path)
|
||||||
|
print(f"[OK] 生成可见性示意图: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ui_screenshot():
|
||||||
|
"""生成 UI 示意图"""
|
||||||
|
output_path = "/tmp/visibility_ui.png"
|
||||||
|
|
||||||
|
img = Image.new('RGB', (700, 250), color='#fafafa')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
try:
|
||||||
|
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16)
|
||||||
|
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12)
|
||||||
|
except:
|
||||||
|
font_medium = ImageFont.load_default()
|
||||||
|
font_small = ImageFont.load_default()
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium)
|
||||||
|
|
||||||
|
# 私有项目选项(选中状态)
|
||||||
|
draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||||
|
draw.ellipse([45, 95, 65, 115], fill='#fa8c16')
|
||||||
|
draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium)
|
||||||
|
draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small)
|
||||||
|
|
||||||
|
# 默认标签
|
||||||
|
draw.rectangle([250, 85, 310, 110], fill='#52c41a')
|
||||||
|
draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small)
|
||||||
|
|
||||||
|
# 企业项目选项(未选中状态)
|
||||||
|
draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1)
|
||||||
|
draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2)
|
||||||
|
draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium)
|
||||||
|
draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small)
|
||||||
|
|
||||||
|
# 底部说明
|
||||||
|
draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1)
|
||||||
|
draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small)
|
||||||
|
draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small)
|
||||||
|
|
||||||
|
img.save(output_path)
|
||||||
|
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
# 文档内容块定义
|
||||||
|
def heading1(text):
|
||||||
|
return {
|
||||||
|
"block_type": 3,
|
||||||
|
"heading1": {"elements": [{"text_run": {"content": text}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def heading2(text):
|
||||||
|
return {
|
||||||
|
"block_type": 4,
|
||||||
|
"heading2": {"elements": [{"text_run": {"content": text}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def heading3(text):
|
||||||
|
return {
|
||||||
|
"block_type": 5,
|
||||||
|
"heading3": {"elements": [{"text_run": {"content": text}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def text_block(content):
|
||||||
|
return {
|
||||||
|
"block_type": 2,
|
||||||
|
"text": {"elements": [{"text_run": {"content": content}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bullet(content):
|
||||||
|
return {
|
||||||
|
"block_type": 12,
|
||||||
|
"bullet": {"elements": [{"text_run": {"content": content}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ordered(content):
|
||||||
|
return {
|
||||||
|
"block_type": 13,
|
||||||
|
"ordered": {"elements": [{"text_run": {"content": content}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def code_block(content, language=1):
|
||||||
|
return {
|
||||||
|
"block_type": 14,
|
||||||
|
"code": {
|
||||||
|
"elements": [{"text_run": {"content": content}}],
|
||||||
|
"language": language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def divider():
|
||||||
|
return {"block_type": 22, "divider": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "#" * 60)
|
||||||
|
print("# 重建飞书文档:ai-proj 项目可见性手册")
|
||||||
|
print("#" * 60)
|
||||||
|
|
||||||
|
# Step 1: 生成示意图
|
||||||
|
print("\n--- Step 1: 生成示意图 ---")
|
||||||
|
diagram_path = generate_visibility_diagram()
|
||||||
|
ui_path = generate_ui_screenshot()
|
||||||
|
|
||||||
|
# Step 2: 清空文档
|
||||||
|
print("\n--- Step 2: 清空文档 ---")
|
||||||
|
clear_document(DOCUMENT_ID)
|
||||||
|
time.sleep(1) # 等待清空完成
|
||||||
|
|
||||||
|
# Step 3: 创建手册内容
|
||||||
|
print("\n--- Step 3: 创建手册内容 ---")
|
||||||
|
|
||||||
|
# 第一部分:标题和概述
|
||||||
|
blocks_part1 = [
|
||||||
|
heading1("ai-proj 项目可见性手册"),
|
||||||
|
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||||||
|
divider(),
|
||||||
|
heading2("1. 功能概述"),
|
||||||
|
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||||||
|
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||||||
|
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||||||
|
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||||||
|
divider(),
|
||||||
|
heading2("2. 可见性对比"),
|
||||||
|
text_block("下图展示了两种可见性的区别:"),
|
||||||
|
]
|
||||||
|
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part1)
|
||||||
|
print(" 第一部分内容创建完成")
|
||||||
|
|
||||||
|
# 插入可见性示意图
|
||||||
|
print("\n--- Step 4: 插入可见性示意图 ---")
|
||||||
|
insert_image(DOCUMENT_ID, diagram_path, "可见性示意图")
|
||||||
|
|
||||||
|
# 第二部分:设置方法
|
||||||
|
print("\n--- Step 5: 添加设置说明 ---")
|
||||||
|
blocks_part2 = [
|
||||||
|
divider(),
|
||||||
|
heading2("3. 设置项目可见性"),
|
||||||
|
heading3("3.1 创建项目时设置"),
|
||||||
|
text_block("在创建项目时,您可以选择项目的可见性:"),
|
||||||
|
ordered("点击「创建项目」按钮"),
|
||||||
|
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||||||
|
ordered("选择「私有项目」或「企业项目」"),
|
||||||
|
ordered("填写其他信息后点击「确定」"),
|
||||||
|
text_block("UI 界面示意:"),
|
||||||
|
]
|
||||||
|
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part2)
|
||||||
|
print(" 设置说明创建完成")
|
||||||
|
|
||||||
|
# 插入 UI 示意图
|
||||||
|
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||||||
|
insert_image(DOCUMENT_ID, ui_path, "UI 示意图")
|
||||||
|
|
||||||
|
# 第三部分:修改方法和规则
|
||||||
|
print("\n--- Step 7: 添加剩余内容 ---")
|
||||||
|
blocks_part3 = [
|
||||||
|
heading3("3.2 修改已有项目"),
|
||||||
|
ordered("在项目列表中找到目标项目"),
|
||||||
|
ordered("点击项目卡片上的「编辑」按钮"),
|
||||||
|
ordered("在编辑页面修改「项目可见性」"),
|
||||||
|
ordered("点击「保存」"),
|
||||||
|
divider(),
|
||||||
|
heading2("4. 可见性规则"),
|
||||||
|
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||||||
|
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||||||
|
bullet("只有项目所有者或管理员可以修改可见性"),
|
||||||
|
bullet("设置为「企业项目」需要项目已关联企业"),
|
||||||
|
divider(),
|
||||||
|
heading2("5. API 参考"),
|
||||||
|
text_block("修改项目可见性的 API:"),
|
||||||
|
code_block('''PATCH /api/v1/projects/:id/visibility
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"visibility": "enterprise"
|
||||||
|
}
|
||||||
|
|
||||||
|
可选值: "private" | "enterprise"'''),
|
||||||
|
divider(),
|
||||||
|
heading2("6. 注意事项"),
|
||||||
|
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||||||
|
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||||||
|
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||||||
|
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||||||
|
divider(),
|
||||||
|
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||||||
|
]
|
||||||
|
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part3)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("文档重建完成!")
|
||||||
|
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 验证图片
|
||||||
|
print("\n--- 验证图片状态 ---")
|
||||||
|
blocks = get_document_blocks(DOCUMENT_ID)
|
||||||
|
image_count = 0
|
||||||
|
for block in blocks:
|
||||||
|
if block.get("block_type") == 27:
|
||||||
|
image_count += 1
|
||||||
|
image_data = block.get("image", {})
|
||||||
|
token = image_data.get("token", "")
|
||||||
|
print(f"图片 #{image_count}: token={'有效' if token else '空'} ({token[:20] if token else 'N/A'}...)")
|
||||||
|
|
||||||
|
if image_count == 0:
|
||||||
|
print("警告:文档中没有图片块")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
530
plugins/feishu-plugin/scripts/aiproj_sync.py
Normal file
530
plugins/feishu-plugin/scripts/aiproj_sync.py
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ai-proj 与飞书项目同步脚本
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 初始化:创建飞书根目录、多维表格
|
||||||
|
- 全量同步:同步所有项目到飞书
|
||||||
|
- 增量同步:只同步新增项目
|
||||||
|
- 更新同步:更新现有项目的任务统计等信息
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
python aiproj_sync.py init # 首次初始化
|
||||||
|
python aiproj_sync.py sync # 增量同步(推荐日常使用)
|
||||||
|
python aiproj_sync.py sync-all # 全量同步
|
||||||
|
python aiproj_sync.py update # 更新统计信息
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
|
||||||
|
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
|
||||||
|
|
||||||
|
# 默认协作者 open_id(邱栋梁@智云采购)
|
||||||
|
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
|
||||||
|
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
|
||||||
|
|
||||||
|
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
|
||||||
|
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
|
||||||
|
|
||||||
|
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 飞书 API
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class FeishuAPI:
|
||||||
|
def __init__(self):
|
||||||
|
self.token = None
|
||||||
|
|
||||||
|
def get_token(self) -> str:
|
||||||
|
"""获取 tenant_access_token"""
|
||||||
|
if self.token:
|
||||||
|
return self.token
|
||||||
|
|
||||||
|
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": FEISHU_APP_ID,
|
||||||
|
"app_secret": FEISHU_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
self.token = data["tenant_access_token"]
|
||||||
|
return self.token
|
||||||
|
else:
|
||||||
|
raise Exception(f"获取飞书 token 失败: {data}")
|
||||||
|
|
||||||
|
def _headers(self, content_type: bool = False) -> Dict:
|
||||||
|
headers = {"Authorization": f"Bearer {self.get_token()}"}
|
||||||
|
if content_type:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def get_root_folder(self) -> str:
|
||||||
|
"""获取云空间根文件夹"""
|
||||||
|
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
|
||||||
|
response = requests.get(url, headers=self._headers())
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["token"]
|
||||||
|
raise Exception(f"获取根文件夹失败: {data}")
|
||||||
|
|
||||||
|
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
|
||||||
|
"""创建文件夹"""
|
||||||
|
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
|
||||||
|
response = requests.post(url, headers=self._headers(True), json={
|
||||||
|
"name": name,
|
||||||
|
"folder_token": parent_token
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
folder_token = data["data"]["token"]
|
||||||
|
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
|
||||||
|
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
|
||||||
|
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
|
||||||
|
return folder_token
|
||||||
|
raise Exception(f"创建文件夹失败: {data}")
|
||||||
|
|
||||||
|
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
|
||||||
|
"""添加协作者
|
||||||
|
|
||||||
|
应用通过 API 创建的文件/文件夹,所有者是应用本身。
|
||||||
|
组织内用户默认无法访问,需要显式添加为协作者。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_token: 文件/文件夹 token
|
||||||
|
file_type: 类型 (folder, doc, sheet, bitable 等)
|
||||||
|
user_open_id: 用户 open_id
|
||||||
|
perm: 权限级别 (full_access, edit, view)
|
||||||
|
"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
|
||||||
|
params = {"type": file_type, "need_notification": "false"}
|
||||||
|
payload = {
|
||||||
|
"member_type": "openid",
|
||||||
|
"member_id": user_open_id,
|
||||||
|
"perm": perm
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
|
||||||
|
return response.json().get("code") == 0
|
||||||
|
|
||||||
|
def create_bitable(self, name: str, folder_token: str) -> Dict:
|
||||||
|
"""创建多维表格"""
|
||||||
|
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
|
||||||
|
response = requests.post(url, headers=self._headers(True), json={
|
||||||
|
"name": name,
|
||||||
|
"folder_token": folder_token
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["app"]
|
||||||
|
raise Exception(f"创建多维表格失败: {data}")
|
||||||
|
|
||||||
|
def get_bitable_tables(self, app_token: str) -> List[Dict]:
|
||||||
|
"""获取数据表列表"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
|
||||||
|
response = requests.get(url, headers=self._headers())
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["items"]
|
||||||
|
raise Exception(f"获取数据表失败: {data}")
|
||||||
|
|
||||||
|
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
|
||||||
|
"""创建字段"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||||
|
response = requests.post(url, headers=self._headers(True), json=field)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["field"]
|
||||||
|
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
|
||||||
|
"""更新字段"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
|
||||||
|
response = requests.put(url, headers=self._headers(True), json=updates)
|
||||||
|
return response.json().get("code") == 0
|
||||||
|
|
||||||
|
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
|
||||||
|
"""获取字段列表"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
||||||
|
response = requests.get(url, headers=self._headers())
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["items"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
|
||||||
|
"""添加记录"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["record"]
|
||||||
|
print(f" 记录添加失败: {data.get('msg')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
|
||||||
|
"""获取记录列表"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
||||||
|
params = {"page_size": 500}
|
||||||
|
if filter_str:
|
||||||
|
params["filter"] = filter_str
|
||||||
|
|
||||||
|
response = requests.get(url, headers=self._headers(), params=params)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"].get("items", [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
|
||||||
|
"""更新记录"""
|
||||||
|
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
|
||||||
|
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
|
||||||
|
return response.json().get("code") == 0
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ai-proj API
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class AIProjAPI:
|
||||||
|
@staticmethod
|
||||||
|
def get_projects() -> List[Dict]:
|
||||||
|
"""获取项目列表"""
|
||||||
|
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
|
||||||
|
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("success"):
|
||||||
|
return data["data"]["data"]
|
||||||
|
raise Exception(f"获取项目列表失败: {data}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 配置管理
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def load_config() -> Optional[Dict]:
|
||||||
|
"""加载配置"""
|
||||||
|
if os.path.exists(CONFIG_PATH):
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_config(config: Dict):
|
||||||
|
"""保存配置"""
|
||||||
|
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||||
|
with open(CONFIG_PATH, "w") as f:
|
||||||
|
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 同步逻辑
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def init():
|
||||||
|
"""初始化:创建飞书目录结构"""
|
||||||
|
print("=" * 50)
|
||||||
|
print("飞书 ai-proj 项目同步 - 初始化")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
if load_config():
|
||||||
|
print("\n已存在配置文件,是否重新初始化?(y/N)")
|
||||||
|
if input().lower() != 'y':
|
||||||
|
print("取消初始化")
|
||||||
|
return
|
||||||
|
|
||||||
|
feishu = FeishuAPI()
|
||||||
|
|
||||||
|
# 1. 获取根目录
|
||||||
|
print("\n[1/5] 获取云空间根目录...")
|
||||||
|
space_root = feishu.get_root_folder()
|
||||||
|
print(f" 根目录: {space_root}")
|
||||||
|
|
||||||
|
# 2. 创建 ai-proj 文件夹
|
||||||
|
print("\n[2/5] 创建 ai-proj 根文件夹...")
|
||||||
|
root_folder = feishu.create_folder("ai-proj", space_root)
|
||||||
|
print(f" 创建成功: {root_folder}")
|
||||||
|
|
||||||
|
# 3. 创建多维表格
|
||||||
|
print("\n[3/5] 创建项目目录多维表格...")
|
||||||
|
bitable = feishu.create_bitable("项目目录", root_folder)
|
||||||
|
app_token = bitable["app_token"]
|
||||||
|
print(f" 创建成功: {app_token}")
|
||||||
|
|
||||||
|
tables = feishu.get_bitable_tables(app_token)
|
||||||
|
table_id = tables[0]["table_id"]
|
||||||
|
|
||||||
|
# 4. 创建字段
|
||||||
|
print("\n[4/5] 创建数据表字段...")
|
||||||
|
|
||||||
|
# 先修改第一列名称
|
||||||
|
fields = feishu.get_fields(app_token, table_id)
|
||||||
|
if fields:
|
||||||
|
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
|
||||||
|
"field_name": "项目名称",
|
||||||
|
"type": fields[0]["type"]
|
||||||
|
})
|
||||||
|
print(" 修改字段: 项目名称")
|
||||||
|
|
||||||
|
# 创建其他字段
|
||||||
|
field_definitions = [
|
||||||
|
{"field_name": "项目ID", "type": 2},
|
||||||
|
{"field_name": "项目编号", "type": 1},
|
||||||
|
{"field_name": "飞书文件夹", "type": 15},
|
||||||
|
{"field_name": "状态", "type": 3, "property": {"options": [
|
||||||
|
{"name": "active", "color": 0},
|
||||||
|
{"name": "on_hold", "color": 1},
|
||||||
|
{"name": "planning", "color": 2},
|
||||||
|
{"name": "completed", "color": 3},
|
||||||
|
{"name": "archived", "color": 4}
|
||||||
|
]}},
|
||||||
|
{"field_name": "优先级", "type": 3, "property": {"options": [
|
||||||
|
{"name": "high", "color": 0},
|
||||||
|
{"name": "medium", "color": 1},
|
||||||
|
{"name": "low", "color": 2}
|
||||||
|
]}},
|
||||||
|
{"field_name": "所属企业", "type": 1},
|
||||||
|
{"field_name": "任务总数", "type": 2},
|
||||||
|
{"field_name": "描述", "type": 1},
|
||||||
|
{"field_name": "创建时间", "type": 5},
|
||||||
|
{"field_name": "最后同步", "type": 5},
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in field_definitions:
|
||||||
|
if feishu.create_field(app_token, table_id, field):
|
||||||
|
print(f" 创建字段: {field['field_name']}")
|
||||||
|
|
||||||
|
# 5. 保存配置
|
||||||
|
print("\n[5/5] 保存配置...")
|
||||||
|
config = {
|
||||||
|
"root_folder_token": root_folder,
|
||||||
|
"bitable_app_token": app_token,
|
||||||
|
"bitable_table_id": table_id,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"synced_projects": {}
|
||||||
|
}
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("初始化完成!")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
|
||||||
|
print(f"多维表格: https://feishu.cn/base/{app_token}")
|
||||||
|
print(f"\n配置文件: {CONFIG_PATH}")
|
||||||
|
print("\n运行 'python aiproj_sync.py sync' 同步项目")
|
||||||
|
|
||||||
|
def sync(full: bool = False):
|
||||||
|
"""同步项目"""
|
||||||
|
config = load_config()
|
||||||
|
if not config:
|
||||||
|
print("未找到配置,请先运行 init")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
feishu = FeishuAPI()
|
||||||
|
app_token = config["bitable_app_token"]
|
||||||
|
table_id = config["bitable_table_id"]
|
||||||
|
root_folder = config["root_folder_token"]
|
||||||
|
synced = config.get("synced_projects", {})
|
||||||
|
|
||||||
|
# 获取 ai-proj 项目
|
||||||
|
print("\n获取 ai-proj 项目列表...")
|
||||||
|
projects = AIProjAPI.get_projects()
|
||||||
|
print(f" 共 {len(projects)} 个项目")
|
||||||
|
|
||||||
|
# 筛选需要同步的项目
|
||||||
|
if full:
|
||||||
|
to_sync = projects
|
||||||
|
else:
|
||||||
|
to_sync = [p for p in projects if str(p["id"]) not in synced]
|
||||||
|
|
||||||
|
if not to_sync:
|
||||||
|
print("\n没有新项目需要同步")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n需要同步 {len(to_sync)} 个项目...")
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
for project in to_sync:
|
||||||
|
project_id = str(project["id"])
|
||||||
|
project_name = project["name"]
|
||||||
|
folder_name = f"{project_name}_{project_id}"
|
||||||
|
|
||||||
|
print(f"\n 处理: {project_name} (ID: {project_id})")
|
||||||
|
|
||||||
|
# 创建文件夹(如果不存在)
|
||||||
|
folder_token = synced.get(project_id, {}).get("folder_token")
|
||||||
|
if not folder_token:
|
||||||
|
try:
|
||||||
|
folder_token = feishu.create_folder(folder_name, root_folder)
|
||||||
|
print(f" 创建文件夹: {folder_token}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 文件夹创建失败: {e}")
|
||||||
|
folder_token = ""
|
||||||
|
|
||||||
|
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
|
||||||
|
|
||||||
|
# 添加/更新记录
|
||||||
|
record_fields = {
|
||||||
|
"项目名称": project_name,
|
||||||
|
"项目ID": int(project_id),
|
||||||
|
"项目编号": project.get("project_number", ""),
|
||||||
|
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
|
||||||
|
"状态": project.get("status", "active"),
|
||||||
|
"优先级": project.get("priority", "medium"),
|
||||||
|
"所属企业": project.get("company_name", ""),
|
||||||
|
"任务总数": project.get("task_count", 0),
|
||||||
|
"描述": (project.get("description", "") or "")[:500],
|
||||||
|
"创建时间": int(datetime.fromisoformat(
|
||||||
|
project["created_at"].replace("Z", "+00:00")
|
||||||
|
).timestamp() * 1000),
|
||||||
|
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
record_id = synced.get(project_id, {}).get("record_id")
|
||||||
|
if record_id and not full:
|
||||||
|
# 更新现有记录
|
||||||
|
if feishu.update_record(app_token, table_id, record_id, record_fields):
|
||||||
|
print(f" 更新记录成功")
|
||||||
|
success += 1
|
||||||
|
else:
|
||||||
|
# 添加新记录
|
||||||
|
record = feishu.add_record(app_token, table_id, record_fields)
|
||||||
|
if record:
|
||||||
|
record_id = record["record_id"]
|
||||||
|
print(f" 添加记录成功")
|
||||||
|
success += 1
|
||||||
|
|
||||||
|
# 更新同步记录
|
||||||
|
synced[project_id] = {
|
||||||
|
"folder_token": folder_token,
|
||||||
|
"folder_url": folder_url,
|
||||||
|
"record_id": record_id,
|
||||||
|
"synced_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 保存配置
|
||||||
|
config["synced_projects"] = synced
|
||||||
|
config["last_sync"] = datetime.now().isoformat()
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"同步完成: {success}/{len(to_sync)}")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
|
||||||
|
|
||||||
|
def update_stats():
|
||||||
|
"""更新任务统计信息"""
|
||||||
|
config = load_config()
|
||||||
|
if not config:
|
||||||
|
print("未找到配置,请先运行 init")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("飞书 ai-proj 项目同步 - 更新统计")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
feishu = FeishuAPI()
|
||||||
|
app_token = config["bitable_app_token"]
|
||||||
|
table_id = config["bitable_table_id"]
|
||||||
|
synced = config.get("synced_projects", {})
|
||||||
|
|
||||||
|
# 获取最新项目数据
|
||||||
|
print("\n获取 ai-proj 项目列表...")
|
||||||
|
projects = AIProjAPI.get_projects()
|
||||||
|
project_map = {str(p["id"]): p for p in projects}
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for project_id, sync_info in synced.items():
|
||||||
|
record_id = sync_info.get("record_id")
|
||||||
|
if not record_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
project = project_map.get(project_id)
|
||||||
|
if not project:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 只更新统计字段
|
||||||
|
update_fields = {
|
||||||
|
"任务总数": project.get("task_count", 0),
|
||||||
|
"状态": project.get("status", "active"),
|
||||||
|
"最后同步": int(datetime.now().timestamp() * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feishu.update_record(app_token, table_id, record_id, update_fields):
|
||||||
|
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
config["last_sync"] = datetime.now().isoformat()
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
print(f"\n更新完成: {updated} 个项目")
|
||||||
|
|
||||||
|
def show_status():
|
||||||
|
"""显示同步状态"""
|
||||||
|
config = load_config()
|
||||||
|
if not config:
|
||||||
|
print("未初始化,请先运行: python aiproj_sync.py init")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("ai-proj 飞书同步状态")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
|
||||||
|
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
|
||||||
|
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
|
||||||
|
print(f"上次同步: {config.get('last_sync', '从未')}")
|
||||||
|
print(f"配置文件: {CONFIG_PATH}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 主入口
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("用法: python aiproj_sync.py <command>")
|
||||||
|
print("\n命令:")
|
||||||
|
print(" init 首次初始化(创建目录和多维表格)")
|
||||||
|
print(" sync 增量同步(只同步新项目)")
|
||||||
|
print(" sync-all 全量同步(同步所有项目)")
|
||||||
|
print(" update 更新统计信息")
|
||||||
|
print(" status 查看同步状态")
|
||||||
|
return
|
||||||
|
|
||||||
|
command = sys.argv[1]
|
||||||
|
|
||||||
|
if command == "init":
|
||||||
|
init()
|
||||||
|
elif command == "sync":
|
||||||
|
sync(full=False)
|
||||||
|
elif command == "sync-all":
|
||||||
|
sync(full=True)
|
||||||
|
elif command == "update":
|
||||||
|
update_stats()
|
||||||
|
elif command == "status":
|
||||||
|
show_status()
|
||||||
|
else:
|
||||||
|
print(f"未知命令: {command}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
139
plugins/feishu-plugin/skills/SKILL.md
Normal file
139
plugins/feishu-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
name: feishu
|
||||||
|
description: 飞书文档与多维表格操作入口。当用户提到飞书、云文档、多维表格、Bitable 相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 飞书集成
|
||||||
|
|
||||||
|
## 功能模块
|
||||||
|
|
||||||
|
| 模块 | 技能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 云文档 | `feishu-docx` | 创建、编辑云文档,会议纪要 |
|
||||||
|
| 多维表格 | `feishu-bitable` | 记录增删改查,数据同步 |
|
||||||
|
| 任务 | 本技能 | 创建待办任务 |
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ~/.zshrc(凭证唯一配置位置)
|
||||||
|
export FEISHU_APP_ID="cli_a9f29dca82b9dbef"
|
||||||
|
export FEISHU_APP_SECRET="<从飞书开放平台获取>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**权限要求**:
|
||||||
|
- 云文档:`docx:document`, `drive:drive`
|
||||||
|
- 多维表格:`bitable:app`
|
||||||
|
- 任务:`task:task:write`
|
||||||
|
|
||||||
|
## Access Token
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os, requests
|
||||||
|
|
||||||
|
def get_tenant_access_token():
|
||||||
|
"""获取飞书 tenant_access_token"""
|
||||||
|
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": os.environ["FEISHU_APP_ID"],
|
||||||
|
"app_secret": os.environ["FEISHU_APP_SECRET"]
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["tenant_access_token"]
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 默认存储位置
|
||||||
|
|
||||||
|
| 文件夹 | folder_token |
|
||||||
|
|--------|-------------|
|
||||||
|
| ai-proj 根目录 | `RTLKf247ClQQDyd5IjxcTOVQnxd` |
|
||||||
|
| 01运营 (默认) | `C80gfkRnzlonQ5d4AhOcOACDnNg` |
|
||||||
|
|
||||||
|
## URL 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
云文档: https://xxx.feishu.cn/docx/DoxcXXXXXX
|
||||||
|
└── document_id
|
||||||
|
|
||||||
|
多维表格: https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX
|
||||||
|
└── app_token └── table_id
|
||||||
|
```
|
||||||
|
|
||||||
|
## 飞书任务
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_task(summary: str, due_time: int = None):
|
||||||
|
"""创建飞书任务"""
|
||||||
|
url = "https://open.feishu.cn/open-apis/task/v2/tasks"
|
||||||
|
token = get_tenant_access_token()
|
||||||
|
payload = {"summary": summary}
|
||||||
|
if due_time:
|
||||||
|
payload["due"] = {"timestamp": str(due_time), "is_all_day": False}
|
||||||
|
response = requests.post(url,
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") == 0:
|
||||||
|
return data["data"]["task"]
|
||||||
|
raise Exception(f"创建任务失败: {data}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具类
|
||||||
|
|
||||||
|
完整工具类见:
|
||||||
|
- `~/.claude/skills/feishu/feishu_bitable.py` - 多维表格
|
||||||
|
- `~/.claude/skills/feishu/feishu_docx.py` - 云文档
|
||||||
|
|
||||||
|
## Incoming Webhook(群机器人通知卡片)
|
||||||
|
|
||||||
|
**Webhook 地址**:存储在 `~/.config/devops/credentials.env` → `FEISHU_DEPLOY_WEBHOOK`
|
||||||
|
|
||||||
|
**⚠️ 关键注意事项**:
|
||||||
|
- ❌ **禁用 schema 2.0**:`"schema": "2.0"` 会返回 ErrCode 11246,必须用 legacy 格式
|
||||||
|
- ✅ **legacy 卡片格式**(无 schema 字段)才能正常发送
|
||||||
|
- ❌ **按钮 URL 禁止指向列表页**:必须带具体资源 ID(如 `/requirements/864`,不能是 `/requirements`)
|
||||||
|
- ✅ **保存到文件再 curl**:包含中文的 JSON 直接用 `'...'` 传参会报 "blank argument" 错误
|
||||||
|
|
||||||
|
**正确的卡片发送示例**:
|
||||||
|
```bash
|
||||||
|
cat > /tmp/feishu_card.json << 'EOF'
|
||||||
|
{
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"card": {
|
||||||
|
"header": {
|
||||||
|
"title": {"tag": "plain_text", "content": "通知标题"},
|
||||||
|
"template": "blue"
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"tag": "div",
|
||||||
|
"text": {"tag": "lark_md", "content": "**内容**:描述文字"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "action",
|
||||||
|
"actions": [{
|
||||||
|
"tag": "button",
|
||||||
|
"text": {"tag": "plain_text", "content": "查看详情"},
|
||||||
|
"type": "primary",
|
||||||
|
"url": "https://ai.pipexerp.com/requirements/864"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://open.feishu.cn/open-apis/bot/v2/hook/xxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @/tmp/feishu_card.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**header template 颜色**:`blue`(待审批)/ `green`(通过)/ `red`(驳回)/ `wathet`(信息)
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
- `feishu-docx` - 云文档详细操作
|
||||||
|
- `feishu-bitable` - 多维表格详细操作
|
||||||
391
plugins/feishu-plugin/test_docx_image.py
Normal file
391
plugins/feishu-plugin/test_docx_image.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
测试飞书云文档图片上传
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
# ========== 配置 ==========
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuDocx:
|
||||||
|
"""飞书云文档操作工具类"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._token = None
|
||||||
|
self._token_expires = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self) -> str:
|
||||||
|
if self._token and self._token_expires and datetime.now() < self._token_expires:
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": ZHIYUN_APP_ID,
|
||||||
|
"app_secret": ZHIYUN_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
self._token = data["tenant_access_token"]
|
||||||
|
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
print(f"[OK] Token 获取成功")
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self):
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_document_permission(self, document_id: str, editable: bool = True) -> bool:
|
||||||
|
"""设置文档权限为组织内可编辑"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"external_access_entity": "open",
|
||||||
|
"security_entity": "anyone_can_view",
|
||||||
|
"comment_entity": "anyone_can_view",
|
||||||
|
"share_entity": "anyone",
|
||||||
|
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||||
|
"invite_external": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload)
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
print(f"[OK] 权限设置成功: 组织内可编辑")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[WARN] 权限设置失败: {result.get('msg')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict:
|
||||||
|
"""创建云文档(自动设置为组织内可编辑)"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents"
|
||||||
|
payload = {"title": title}
|
||||||
|
if folder_token:
|
||||||
|
payload["folder_token"] = folder_token
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self.headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建文档失败: {data}")
|
||||||
|
|
||||||
|
doc = data["data"]["document"]
|
||||||
|
print(f"[OK] 文档创建成功: {doc['document_id']}")
|
||||||
|
|
||||||
|
# 自动设置权限
|
||||||
|
if editable:
|
||||||
|
self.set_document_permission(doc['document_id'], editable=True)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
def create_empty_image_block(self, document_id: str, index: int = -1) -> str:
|
||||||
|
"""
|
||||||
|
创建空的图片块,返回 block_id
|
||||||
|
|
||||||
|
正确流程第一步:先创建空图片块
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
|
||||||
|
|
||||||
|
# 创建空的图片块
|
||||||
|
image_block = {
|
||||||
|
"block_type": 27,
|
||||||
|
"image": {} # 空的图片块
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {"children": [image_block]}
|
||||||
|
if index >= 0:
|
||||||
|
payload["index"] = index
|
||||||
|
|
||||||
|
response = requests.post(url, headers=self.headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建图片块失败: {data}")
|
||||||
|
|
||||||
|
# 获取创建的图片块 ID
|
||||||
|
children = data["data"].get("children", [])
|
||||||
|
if not children:
|
||||||
|
raise Exception("创建图片块失败: 没有返回 children")
|
||||||
|
|
||||||
|
block_id = children[0].get("block_id")
|
||||||
|
print(f"[OK] 空图片块创建成功, block_id: {block_id}")
|
||||||
|
return block_id
|
||||||
|
|
||||||
|
def upload_image_to_block(self, file_path: str, block_id: str) -> str:
|
||||||
|
"""
|
||||||
|
上传图片到指定的图片块
|
||||||
|
|
||||||
|
正确流程第二步:将图片上传并绑定到已创建的图片块
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {self.token}"}
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, 'image/png')}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': 'docx_image', # 云文档图片
|
||||||
|
'parent_node': block_id, # 关键: 使用图片块的 block_id
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"[INFO] 上传图片到 block_id={block_id}")
|
||||||
|
response = requests.post(url, headers=headers, files=files, data=data)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
print(f"[DEBUG] 上传响应: code={result.get('code')}, msg={result.get('msg')}")
|
||||||
|
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise Exception(f"上传图片失败: {result}")
|
||||||
|
|
||||||
|
file_token = result["data"]["file_token"]
|
||||||
|
print(f"[OK] 图片上传成功, file_token: {file_token}")
|
||||||
|
return file_token
|
||||||
|
|
||||||
|
def _upload_media(self, file_path: str, parent_type: str, parent_node: str = '') -> str:
|
||||||
|
"""使用 media API 上传"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
headers = {"Authorization": f"Bearer {self.token}"}
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, 'image/png')}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': parent_type,
|
||||||
|
'parent_node': parent_node,
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, files=files, data=data)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
print(f"[DEBUG] media 响应 ({parent_type}): code={result.get('code')}, msg={result.get('msg')}")
|
||||||
|
|
||||||
|
if result.get("code") == 0:
|
||||||
|
return result["data"]["file_token"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _upload_to_drive(self, file_path: str) -> str:
|
||||||
|
"""上传到云空间根目录,然后获取 file_token"""
|
||||||
|
# 先获取根文件夹 token
|
||||||
|
url = f"{BASE_URL}/drive/explorer/v2/root_folder/meta"
|
||||||
|
headers = {"Authorization": f"Bearer {self.token}"}
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
result = response.json()
|
||||||
|
print(f"[DEBUG] 根目录响应: {result}")
|
||||||
|
|
||||||
|
if result.get("code") != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
root_token = result["data"]["token"]
|
||||||
|
|
||||||
|
# 上传文件
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
upload_url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, 'image/png')}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': 'explorer',
|
||||||
|
'parent_node': root_token,
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(upload_url, headers=headers, files=files, data=data)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
print(f"[DEBUG] drive 上传响应: code={result.get('code')}, msg={result.get('msg')}")
|
||||||
|
|
||||||
|
if result.get("code") == 0:
|
||||||
|
return result["data"]["file_token"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _upload_im_image(self, file_path: str) -> str:
|
||||||
|
"""使用消息图片 API 上传"""
|
||||||
|
url = f"{BASE_URL}/im/v1/images"
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {self.token}"}
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'image': f}
|
||||||
|
data = {'image_type': 'message'}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, files=files, data=data)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
print(f"[DEBUG] im 图片响应: code={result.get('code')}, msg={result.get('msg')}")
|
||||||
|
|
||||||
|
if result.get("code") == 0:
|
||||||
|
return result["data"]["image_key"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _try_upload_with_type(self, file_path: str, parent_type: str) -> str:
|
||||||
|
"""尝试不同的 parent_type 上传"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {self.token}"}
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, 'image/png')}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': parent_type,
|
||||||
|
'parent_node': '',
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"[INFO] 尝试 parent_type={parent_type}")
|
||||||
|
response = requests.post(url, headers=headers, files=files, data=data)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
print(f"[DEBUG] 响应: {result}")
|
||||||
|
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise Exception(f"上传失败 ({parent_type}): {result}")
|
||||||
|
|
||||||
|
return result["data"]["file_token"]
|
||||||
|
|
||||||
|
def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> dict:
|
||||||
|
"""
|
||||||
|
绑定图片到图片块 (关键的第三步!)
|
||||||
|
|
||||||
|
使用 PATCH 请求和 replace_image 字段将图片绑定到已创建的图片块
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||||
|
|
||||||
|
params = {"document_revision_id": -1}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"replace_image": {
|
||||||
|
"token": file_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"[INFO] 绑定图片到块: block_id={block_id}")
|
||||||
|
response = requests.patch(url, headers=self.headers, params=params, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"绑定图片失败: {data}")
|
||||||
|
|
||||||
|
print(f"[OK] 图片绑定成功")
|
||||||
|
return data["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_test_image(output_path: str):
|
||||||
|
"""生成测试图片"""
|
||||||
|
img = Image.new('RGB', (400, 300), color='#4a90d9')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.rectangle([20, 20, 380, 280], fill='#f0f4f8', outline='#2563eb', width=2)
|
||||||
|
draw.text((200, 150), "Test Image", fill='#1e3a5f', anchor='mm')
|
||||||
|
draw.text((200, 200), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fill='#6b7280', anchor='mm')
|
||||||
|
img.save(output_path)
|
||||||
|
print(f"[OK] 测试图片生成: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "#" * 60)
|
||||||
|
print("# 飞书云文档图片上传测试")
|
||||||
|
print("#" * 60)
|
||||||
|
|
||||||
|
docx = FeishuDocx()
|
||||||
|
|
||||||
|
# Step 1: 生成测试图片
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 1: 生成测试图片")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
test_image = "/tmp/feishu_docx_test_image.png"
|
||||||
|
generate_test_image(test_image)
|
||||||
|
|
||||||
|
# Step 2: 创建测试文档
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 2: 创建测试文档")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
doc = docx.create_document(f"图片上传测试 - {timestamp}")
|
||||||
|
document_id = doc["document_id"]
|
||||||
|
|
||||||
|
# Step 3: 创建空的图片块
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 3: 创建空的图片块")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
block_id = docx.create_empty_image_block(document_id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 创建图片块失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 4: 上传图片
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 4: 上传图片")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_token = docx.upload_image_to_block(test_image, block_id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 上传图片失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 5: 绑定图片到图片块 (关键步骤!)
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Step 5: 绑定图片到图片块")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
docx.bind_image_to_block(document_id, block_id, file_token)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 绑定图片失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 完成
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("测试完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"\n文档地址:")
|
||||||
|
print(f" https://feishu.cn/docx/{document_id}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def check_permissions(docx):
|
||||||
|
"""检查应用权限"""
|
||||||
|
# 获取应用信息
|
||||||
|
url = f"{BASE_URL}/application/v6/applications/underauditlist"
|
||||||
|
headers = {"Authorization": f"Bearer {docx.token}"}
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
print(f"[DEBUG] 权限检查响应: {response.json()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
375
plugins/feishu-plugin/update_visibility_manual.py
Normal file
375
plugins/feishu-plugin/update_visibility_manual.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
更新飞书文档:ai-proj 项目可见性手册
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
# 目标文档
|
||||||
|
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||||||
|
|
||||||
|
_token = None
|
||||||
|
_token_expires = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
global _token, _token_expires
|
||||||
|
if _token and _token_expires and datetime.now() < _token_expires:
|
||||||
|
return _token
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": ZHIYUN_APP_ID,
|
||||||
|
"app_secret": ZHIYUN_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
_token = data["tenant_access_token"]
|
||||||
|
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
return _token
|
||||||
|
|
||||||
|
|
||||||
|
def headers():
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {get_token()}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_blocks(document_id: str):
|
||||||
|
"""获取文档所有块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||||||
|
response = requests.get(url, headers=headers())
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取块失败: {data}")
|
||||||
|
return data["data"].get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_block(document_id: str, block_id: str):
|
||||||
|
"""删除块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||||
|
response = requests.delete(url, headers=headers(), params={"document_revision_id": -1})
|
||||||
|
return response.json().get("code") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||||||
|
"""创建内容块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||||
|
payload = {"children": blocks}
|
||||||
|
response = requests.post(url, headers=headers(), json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建块失败: {data}")
|
||||||
|
return data["data"].get("children", [])
|
||||||
|
|
||||||
|
|
||||||
|
def create_image_block(document_id: str, parent_id: str):
|
||||||
|
"""创建空图片块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||||||
|
payload = {
|
||||||
|
"children": [{
|
||||||
|
"block_type": 27,
|
||||||
|
"image": {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=headers(), json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建图片块失败: {data}")
|
||||||
|
return data["data"]["children"][0]["block_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(file_path: str, block_id: str):
|
||||||
|
"""上传图片"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, 'image/png')}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': 'docx_image',
|
||||||
|
'parent_node': block_id,
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {get_token()}"},
|
||||||
|
files=files,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise Exception(f"上传失败: {result}")
|
||||||
|
return result["data"]["file_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||||
|
"""绑定图片到图片块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||||
|
payload = {"replace_image": {"token": file_token}}
|
||||||
|
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"绑定失败: {data}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def generate_visibility_diagram():
|
||||||
|
"""生成可见性示意图"""
|
||||||
|
output_path = "/tmp/visibility_diagram.png"
|
||||||
|
|
||||||
|
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
draw.rectangle([0, 0, 800, 50], fill='#1890ff')
|
||||||
|
draw.text((400, 25), "项目可见性示意图", fill='white', anchor='mm')
|
||||||
|
|
||||||
|
# 私有项目
|
||||||
|
draw.rectangle([50, 80, 370, 350], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||||
|
draw.text((210, 100), "🔒 私有项目 (private)", fill='#fa8c16', anchor='mm')
|
||||||
|
draw.text((210, 140), "仅创建者可见", fill='#666666', anchor='mm')
|
||||||
|
|
||||||
|
# 私有项目内的人员图标
|
||||||
|
draw.ellipse([180, 180, 240, 240], fill='#fa8c16')
|
||||||
|
draw.text((210, 210), "👤", fill='white', anchor='mm')
|
||||||
|
draw.text((210, 270), "项目所有者", fill='#333333', anchor='mm')
|
||||||
|
draw.text((210, 300), "管理员", fill='#999999', anchor='mm')
|
||||||
|
|
||||||
|
# 企业项目
|
||||||
|
draw.rectangle([430, 80, 750, 350], fill='#e6f7ff', outline='#1890ff', width=2)
|
||||||
|
draw.text((590, 100), "👥 企业项目 (enterprise)", fill='#1890ff', anchor='mm')
|
||||||
|
draw.text((590, 140), "企业内所有成员可见", fill='#666666', anchor='mm')
|
||||||
|
|
||||||
|
# 企业项目内的人员图标组
|
||||||
|
positions = [(520, 190), (590, 190), (660, 190), (555, 250), (625, 250)]
|
||||||
|
for x, y in positions:
|
||||||
|
draw.ellipse([x-20, y-20, x+20, y+20], fill='#1890ff')
|
||||||
|
draw.text((x, y), "👤", fill='white', anchor='mm')
|
||||||
|
|
||||||
|
draw.text((590, 300), "企业所有成员", fill='#333333', anchor='mm')
|
||||||
|
draw.text((590, 330), "管理员", fill='#999999', anchor='mm')
|
||||||
|
|
||||||
|
# 箭头
|
||||||
|
draw.text((400, 200), "→", fill='#333333', anchor='mm')
|
||||||
|
|
||||||
|
img.save(output_path)
|
||||||
|
print(f"[OK] 生成可见性示意图: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ui_screenshot():
|
||||||
|
"""生成 UI 示意图"""
|
||||||
|
output_path = "/tmp/visibility_ui.png"
|
||||||
|
|
||||||
|
img = Image.new('RGB', (600, 200), color='#fafafa')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
draw.text((20, 20), "项目可见性选择", fill='#333333')
|
||||||
|
|
||||||
|
# Radio Group 模拟
|
||||||
|
# 私有项目选项
|
||||||
|
draw.rectangle([20, 60, 280, 100], fill='#fff7e6', outline='#fa8c16', width=2)
|
||||||
|
draw.ellipse([30, 70, 50, 90], fill='#fa8c16')
|
||||||
|
draw.text((60, 80), "🔒 私有项目", fill='#fa8c16', anchor='lm')
|
||||||
|
|
||||||
|
# 企业项目选项
|
||||||
|
draw.rectangle([300, 60, 560, 100], fill='#ffffff', outline='#d9d9d9', width=1)
|
||||||
|
draw.ellipse([310, 70, 330, 90], outline='#d9d9d9', width=1)
|
||||||
|
draw.text((340, 80), "👥 企业项目", fill='#666666', anchor='lm')
|
||||||
|
|
||||||
|
# 说明文字
|
||||||
|
draw.text((20, 130), "私有项目仅创建者可见,企业项目对企业内所有成员可见", fill='#999999')
|
||||||
|
|
||||||
|
# 默认标签
|
||||||
|
draw.rectangle([200, 65, 270, 85], fill='#52c41a')
|
||||||
|
draw.text((235, 75), "默认", fill='white', anchor='mm')
|
||||||
|
|
||||||
|
img.save(output_path)
|
||||||
|
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
# 文档内容块定义
|
||||||
|
def heading1(text):
|
||||||
|
return {
|
||||||
|
"block_type": 3,
|
||||||
|
"heading1": {"elements": [{"text_run": {"content": text}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def heading2(text):
|
||||||
|
return {
|
||||||
|
"block_type": 4,
|
||||||
|
"heading2": {"elements": [{"text_run": {"content": text}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def heading3(text):
|
||||||
|
return {
|
||||||
|
"block_type": 5,
|
||||||
|
"heading3": {"elements": [{"text_run": {"content": text}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def text_block(content):
|
||||||
|
return {
|
||||||
|
"block_type": 2,
|
||||||
|
"text": {"elements": [{"text_run": {"content": content}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bullet(content):
|
||||||
|
return {
|
||||||
|
"block_type": 12,
|
||||||
|
"bullet": {"elements": [{"text_run": {"content": content}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ordered(content):
|
||||||
|
return {
|
||||||
|
"block_type": 13,
|
||||||
|
"ordered": {"elements": [{"text_run": {"content": content}}]}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def code_block(content, language="json"):
|
||||||
|
return {
|
||||||
|
"block_type": 14,
|
||||||
|
"code": {
|
||||||
|
"elements": [{"text_run": {"content": content}}],
|
||||||
|
"language": 1 if language == "json" else 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def divider():
|
||||||
|
return {"block_type": 22, "divider": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "#" * 60)
|
||||||
|
print("# 更新飞书文档:ai-proj 项目可见性手册")
|
||||||
|
print("#" * 60)
|
||||||
|
|
||||||
|
# Step 1: 生成示意图
|
||||||
|
print("\n--- Step 1: 生成示意图 ---")
|
||||||
|
diagram_path = generate_visibility_diagram()
|
||||||
|
ui_path = generate_ui_screenshot()
|
||||||
|
|
||||||
|
# Step 2: 直接追加内容(不删除现有内容)
|
||||||
|
print("\n--- Step 2: 追加手册内容 ---")
|
||||||
|
print("\n--- Step 3: 创建手册内容 ---")
|
||||||
|
|
||||||
|
# 定义手册内容
|
||||||
|
manual_blocks = [
|
||||||
|
heading1("ai-proj 项目可见性手册"),
|
||||||
|
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||||||
|
divider(),
|
||||||
|
|
||||||
|
heading2("1. 功能概述"),
|
||||||
|
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||||||
|
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||||||
|
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||||||
|
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||||||
|
divider(),
|
||||||
|
|
||||||
|
heading2("2. 可见性对比"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 创建第一批内容块
|
||||||
|
created = create_blocks(DOCUMENT_ID, DOCUMENT_ID, manual_blocks)
|
||||||
|
print(f" 创建了 {len(created)} 个内容块")
|
||||||
|
|
||||||
|
# Step 4: 插入可见性示意图
|
||||||
|
print("\n--- Step 4: 插入可见性示意图 ---")
|
||||||
|
img_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
|
||||||
|
print(f" 图片块: {img_block_id}")
|
||||||
|
file_token = upload_image(diagram_path, img_block_id)
|
||||||
|
print(f" file_token: {file_token}")
|
||||||
|
bind_image(DOCUMENT_ID, img_block_id, file_token)
|
||||||
|
print(" 图片绑定成功!")
|
||||||
|
|
||||||
|
# Step 5: 继续添加内容
|
||||||
|
print("\n--- Step 5: 添加更多内容 ---")
|
||||||
|
more_blocks = [
|
||||||
|
divider(),
|
||||||
|
heading2("3. 设置项目可见性"),
|
||||||
|
|
||||||
|
heading3("3.1 创建项目时设置"),
|
||||||
|
ordered("点击「创建项目」按钮"),
|
||||||
|
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||||||
|
ordered("选择「私有项目」或「企业项目」"),
|
||||||
|
ordered("填写其他信息后点击「确定」"),
|
||||||
|
]
|
||||||
|
create_blocks(DOCUMENT_ID, DOCUMENT_ID, more_blocks)
|
||||||
|
|
||||||
|
# Step 6: 插入 UI 示意图
|
||||||
|
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||||||
|
ui_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
|
||||||
|
ui_file_token = upload_image(ui_path, ui_block_id)
|
||||||
|
bind_image(DOCUMENT_ID, ui_block_id, ui_file_token)
|
||||||
|
print(" UI 示意图上传成功!")
|
||||||
|
|
||||||
|
# Step 7: 添加剩余内容
|
||||||
|
print("\n--- Step 7: 添加剩余内容 ---")
|
||||||
|
final_blocks = [
|
||||||
|
heading3("3.2 修改已有项目"),
|
||||||
|
ordered("在项目列表中找到目标项目"),
|
||||||
|
ordered("点击项目卡片上的「编辑」按钮"),
|
||||||
|
ordered("在编辑页面修改「项目可见性」"),
|
||||||
|
ordered("点击「保存」"),
|
||||||
|
divider(),
|
||||||
|
|
||||||
|
heading2("4. 可见性规则"),
|
||||||
|
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||||||
|
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||||||
|
bullet("只有项目所有者或管理员可以修改可见性"),
|
||||||
|
bullet("设置为「企业项目」需要项目已关联企业"),
|
||||||
|
divider(),
|
||||||
|
|
||||||
|
heading2("5. API 参考"),
|
||||||
|
text_block("修改项目可见性的 API:"),
|
||||||
|
code_block('''PATCH /api/v1/projects/:id/visibility
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"visibility": "enterprise"
|
||||||
|
}
|
||||||
|
|
||||||
|
可选值: "private" | "enterprise"'''),
|
||||||
|
divider(),
|
||||||
|
|
||||||
|
heading2("6. 注意事项"),
|
||||||
|
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||||||
|
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||||||
|
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||||||
|
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||||||
|
divider(),
|
||||||
|
|
||||||
|
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||||||
|
]
|
||||||
|
create_blocks(DOCUMENT_ID, DOCUMENT_ID, final_blocks)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("文档更新完成!")
|
||||||
|
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
212
plugins/feishu-plugin/upload_user_image.py
Normal file
212
plugins/feishu-plugin/upload_user_image.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
上传用户指定的图片到飞书云文档
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||||||
|
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||||||
|
BASE_URL = "https://open.feishu.cn/open-apis"
|
||||||
|
|
||||||
|
# 用户指定的图片
|
||||||
|
IMAGE_PATH = "/Users/donglinlai/Downloads/u274.png"
|
||||||
|
|
||||||
|
_token = None
|
||||||
|
_token_expires = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
global _token, _token_expires
|
||||||
|
if _token and _token_expires and datetime.now() < _token_expires:
|
||||||
|
return _token
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(url, json={
|
||||||
|
"app_id": ZHIYUN_APP_ID,
|
||||||
|
"app_secret": ZHIYUN_APP_SECRET
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"获取 token 失败: {data}")
|
||||||
|
|
||||||
|
_token = data["tenant_access_token"]
|
||||||
|
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||||||
|
return _token
|
||||||
|
|
||||||
|
|
||||||
|
def headers():
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {get_token()}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_document_permission(document_id: str, editable: bool = True):
|
||||||
|
"""
|
||||||
|
设置文档权限
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: 文档ID
|
||||||
|
editable: True=组织内可编辑, False=组织内只读
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"external_access_entity": "open",
|
||||||
|
"security_entity": "anyone_can_view",
|
||||||
|
"comment_entity": "anyone_can_view",
|
||||||
|
"share_entity": "anyone",
|
||||||
|
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
|
||||||
|
"invite_external": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("code") == 0:
|
||||||
|
print(f" 权限设置成功: {'组织内可编辑' if editable else '组织内只读'}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" [WARN] 权限设置失败: {data.get('msg')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_document(title: str, editable: bool = True):
|
||||||
|
"""创建文档并设置权限"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents"
|
||||||
|
response = requests.post(url, headers=headers(), json={"title": title})
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建文档失败: {data}")
|
||||||
|
|
||||||
|
doc = data["data"]["document"]
|
||||||
|
document_id = doc["document_id"]
|
||||||
|
|
||||||
|
# 自动设置权限
|
||||||
|
if editable:
|
||||||
|
set_document_permission(document_id, editable=True)
|
||||||
|
|
||||||
|
return document_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_image_block(document_id: str):
|
||||||
|
"""创建空图片块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
|
||||||
|
payload = {
|
||||||
|
"children": [{
|
||||||
|
"block_type": 27,
|
||||||
|
"image": {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=headers(), json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"创建图片块失败: {data}")
|
||||||
|
return data["data"]["children"][0]["block_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(file_path: str, block_id: str):
|
||||||
|
"""上传图片"""
|
||||||
|
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||||||
|
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
# 根据扩展名设置 MIME 类型
|
||||||
|
ext = file_name.lower().split('.')[-1]
|
||||||
|
mime_types = {
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'webp': 'image/webp'
|
||||||
|
}
|
||||||
|
mime_type = mime_types.get(ext, 'image/png')
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {'file': (file_name, f, mime_type)}
|
||||||
|
data = {
|
||||||
|
'file_name': file_name,
|
||||||
|
'parent_type': 'docx_image',
|
||||||
|
'parent_node': block_id,
|
||||||
|
'size': str(file_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {get_token()}"},
|
||||||
|
files=files,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise Exception(f"上传失败: {result}")
|
||||||
|
|
||||||
|
return result["data"]["file_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def bind_image(document_id: str, block_id: str, file_token: str):
|
||||||
|
"""绑定图片到图片块"""
|
||||||
|
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||||||
|
payload = {
|
||||||
|
"replace_image": {
|
||||||
|
"token": file_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = requests.patch(
|
||||||
|
url,
|
||||||
|
headers=headers(),
|
||||||
|
params={"document_revision_id": -1},
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"绑定失败: {data}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"\n上传图片: {IMAGE_PATH}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 检查文件
|
||||||
|
if not os.path.exists(IMAGE_PATH):
|
||||||
|
print(f"[ERROR] 文件不存在: {IMAGE_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
file_size = os.path.getsize(IMAGE_PATH)
|
||||||
|
print(f"文件大小: {file_size / 1024:.1f} KB")
|
||||||
|
|
||||||
|
# Step 1: 创建文档
|
||||||
|
print("\n[1/4] 创建飞书文档...")
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
doc_id = create_document(f"火把图片 - {timestamp}")
|
||||||
|
print(f" 文档ID: {doc_id}")
|
||||||
|
|
||||||
|
# Step 2: 创建图片块
|
||||||
|
print("[2/4] 创建图片块...")
|
||||||
|
block_id = create_image_block(doc_id)
|
||||||
|
print(f" 块ID: {block_id}")
|
||||||
|
|
||||||
|
# Step 3: 上传图片
|
||||||
|
print("[3/4] 上传图片...")
|
||||||
|
file_token = upload_image(IMAGE_PATH, block_id)
|
||||||
|
print(f" file_token: {file_token}")
|
||||||
|
|
||||||
|
# Step 4: 绑定图片
|
||||||
|
print("[4/4] 绑定图片...")
|
||||||
|
bind_image(doc_id, block_id, file_token)
|
||||||
|
print(" 绑定成功!")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("上传完成!")
|
||||||
|
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
plugins/feishu-plugin/users.json
Normal file
10
plugins/feishu-plugin/users.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"吴薇儿": {
|
||||||
|
"email": "wuweier@zhiyuncai.com",
|
||||||
|
"open_id": "ou_1d5cdfee78cbe6f8acc0751fff00ed09"
|
||||||
|
},
|
||||||
|
"宋佳香": {
|
||||||
|
"email": "songjiaxiang@zhiyuncai.com",
|
||||||
|
"open_id": "e6e72eb8"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
plugins/finance-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/finance-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "finance-plugin",
|
||||||
|
"description": "Plugin for finance",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
948
plugins/finance-plugin/skills/SKILL.md
Normal file
948
plugins/finance-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,948 @@
|
|||||||
|
---
|
||||||
|
name: finance
|
||||||
|
description: 财务对账技能。支持银行对账、应收对账、应付对账、内部往来对账等。通过自然语言实现账单导入、差异分析、对账报告生成。当用户提到对账、银行流水、应收应付、账单核对、财务核销相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 财务对账技能
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
- **银行对账**: 银行流水 vs 系统收付款记录匹配
|
||||||
|
- **应收对账**: 客户对账单生成与核对
|
||||||
|
- **应付对账**: 供应商对账单核对
|
||||||
|
- **内部对账**: 公司间/部门间往来核对
|
||||||
|
- **差异分析**: 自动识别未达账项、差异原因
|
||||||
|
- **对账报告**: 生成标准化对账报告
|
||||||
|
- **扫描单据OCR**: PDF/图片扫描件识别,提取订单号与对账单匹配(适用于诉讼证据核对)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对账类型
|
||||||
|
|
||||||
|
### 1. 银行对账
|
||||||
|
|
||||||
|
| 对账项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 银行已收/企业未收 | 银行已入账,系统未记录 |
|
||||||
|
| 企业已收/银行未收 | 系统已记录,银行未入账 |
|
||||||
|
| 银行已付/企业未付 | 银行已扣款,系统未记录 |
|
||||||
|
| 企业已付/银行未付 | 系统已记录,银行未扣款 |
|
||||||
|
|
||||||
|
### 2. 应收对账
|
||||||
|
|
||||||
|
- 客户欠款余额核对
|
||||||
|
- 发票与回款匹配
|
||||||
|
- 账龄分析
|
||||||
|
|
||||||
|
### 3. 应付对账
|
||||||
|
|
||||||
|
- 供应商应付余额核对
|
||||||
|
- 采购入库与付款匹配
|
||||||
|
- 预付款冲销
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据格式要求
|
||||||
|
|
||||||
|
### 银行流水格式
|
||||||
|
|
||||||
|
```csv
|
||||||
|
交易日期,交易类型,摘要,金额,余额,对方账户,对方名称,交易流水号
|
||||||
|
2026-01-15,收入,货款,50000.00,150000.00,622848******1234,深圳XX公司,TXN20260115001
|
||||||
|
2026-01-16,支出,采购款,-30000.00,120000.00,622848******5678,广州YY公司,TXN20260116001
|
||||||
|
```
|
||||||
|
|
||||||
|
### 系统收付款格式
|
||||||
|
|
||||||
|
```csv
|
||||||
|
单据日期,单据类型,单据编号,金额,客户/供应商,备注,关联流水号
|
||||||
|
2026-01-15,收款单,SK20260115001,50000.00,深圳XX公司,货款,TXN20260115001
|
||||||
|
2026-01-16,付款单,FK20260116001,30000.00,广州YY公司,采购款,
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自然语言操作示例
|
||||||
|
|
||||||
|
### 银行对账
|
||||||
|
|
||||||
|
| 用户说 | 执行操作 |
|
||||||
|
|--------|----------|
|
||||||
|
| "导入银行流水" | 解析银行导出的 Excel/CSV |
|
||||||
|
| "和系统记录对账" | 自动匹配流水号/金额/日期 |
|
||||||
|
| "找出未达账项" | 识别双方差异记录 |
|
||||||
|
| "生成银行余额调节表" | 输出标准调节表 |
|
||||||
|
|
||||||
|
### 应收对账
|
||||||
|
|
||||||
|
| 用户说 | 执行操作 |
|
||||||
|
|--------|----------|
|
||||||
|
| "生成客户对账单" | 按客户汇总应收明细 |
|
||||||
|
| "核对XX公司的应收款" | 单客户对账 |
|
||||||
|
| "应收账龄分析" | 按账龄分段统计 |
|
||||||
|
| "哪些发票还没收到款" | 查询未核销发票 |
|
||||||
|
|
||||||
|
### 应付对账
|
||||||
|
|
||||||
|
| 用户说 | 执行操作 |
|
||||||
|
|--------|----------|
|
||||||
|
| "核对供应商对账单" | 导入并匹配供应商账单 |
|
||||||
|
| "本月应付汇总" | 按供应商统计应付 |
|
||||||
|
| "哪些采购单还没付款" | 查询未付款采购单 |
|
||||||
|
|
||||||
|
### 扫描单据OCR对账
|
||||||
|
|
||||||
|
| 用户说 | 执行操作 |
|
||||||
|
|--------|----------|
|
||||||
|
| "识别扫描件与对账单匹配" | OCR 提取 PDF 订单号并与 Excel 对账 |
|
||||||
|
| "承运商起诉了,核对证据" | 诉讼证据对账分析 |
|
||||||
|
| "PDF发票识别" | OCR 提取发票信息 |
|
||||||
|
| "找出对账单没有的订单" | 差异订单识别 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python 代码模板
|
||||||
|
|
||||||
|
### 银行对账核心逻辑
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def load_bank_statement(file_path: str) -> pd.DataFrame:
|
||||||
|
"""加载银行流水"""
|
||||||
|
df = pd.read_excel(file_path) if file_path.endswith('.xlsx') else pd.read_csv(file_path)
|
||||||
|
|
||||||
|
# 标准化列名
|
||||||
|
column_mapping = {
|
||||||
|
'交易日期': 'date',
|
||||||
|
'金额': 'amount',
|
||||||
|
'交易流水号': 'ref_no',
|
||||||
|
'对方名称': 'counterparty',
|
||||||
|
'摘要': 'memo'
|
||||||
|
}
|
||||||
|
df = df.rename(columns=column_mapping)
|
||||||
|
df['date'] = pd.to_datetime(df['date'])
|
||||||
|
df['source'] = 'bank'
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def load_system_records(file_path: str) -> pd.DataFrame:
|
||||||
|
"""加载系统收付款记录"""
|
||||||
|
df = pd.read_excel(file_path) if file_path.endswith('.xlsx') else pd.read_csv(file_path)
|
||||||
|
|
||||||
|
column_mapping = {
|
||||||
|
'单据日期': 'date',
|
||||||
|
'金额': 'amount',
|
||||||
|
'关联流水号': 'ref_no',
|
||||||
|
'客户/供应商': 'counterparty',
|
||||||
|
'单据编号': 'doc_no'
|
||||||
|
}
|
||||||
|
df = df.rename(columns=column_mapping)
|
||||||
|
df['date'] = pd.to_datetime(df['date'])
|
||||||
|
df['source'] = 'system'
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def reconcile(bank_df: pd.DataFrame, system_df: pd.DataFrame) -> dict:
|
||||||
|
"""
|
||||||
|
执行对账
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
'matched': 已匹配记录,
|
||||||
|
'bank_only': 银行有/系统无 (银行未达),
|
||||||
|
'system_only': 系统有/银行无 (企业未达),
|
||||||
|
'summary': 对账汇总
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 方法1: 按流水号精确匹配
|
||||||
|
bank_with_ref = bank_df[bank_df['ref_no'].notna() & (bank_df['ref_no'] != '')]
|
||||||
|
system_with_ref = system_df[system_df['ref_no'].notna() & (system_df['ref_no'] != '')]
|
||||||
|
|
||||||
|
matched_by_ref = pd.merge(
|
||||||
|
bank_with_ref,
|
||||||
|
system_with_ref,
|
||||||
|
on='ref_no',
|
||||||
|
suffixes=('_bank', '_system'),
|
||||||
|
how='inner'
|
||||||
|
)
|
||||||
|
|
||||||
|
matched_refs = set(matched_by_ref['ref_no'])
|
||||||
|
|
||||||
|
# 方法2: 无流水号的按金额+日期模糊匹配
|
||||||
|
bank_unmatched = bank_df[~bank_df['ref_no'].isin(matched_refs)]
|
||||||
|
system_unmatched = system_df[~system_df['ref_no'].isin(matched_refs)]
|
||||||
|
|
||||||
|
# 按金额和日期(±3天)匹配
|
||||||
|
fuzzy_matched = []
|
||||||
|
bank_remaining = bank_unmatched.copy()
|
||||||
|
system_remaining = system_unmatched.copy()
|
||||||
|
|
||||||
|
for idx, bank_row in bank_unmatched.iterrows():
|
||||||
|
for sys_idx, sys_row in system_remaining.iterrows():
|
||||||
|
if (abs(bank_row['amount'] - sys_row['amount']) < 0.01 and
|
||||||
|
abs((bank_row['date'] - sys_row['date']).days) <= 3):
|
||||||
|
fuzzy_matched.append({
|
||||||
|
'bank_idx': idx,
|
||||||
|
'system_idx': sys_idx,
|
||||||
|
'amount': bank_row['amount'],
|
||||||
|
'bank_date': bank_row['date'],
|
||||||
|
'system_date': sys_row['date']
|
||||||
|
})
|
||||||
|
system_remaining = system_remaining.drop(sys_idx)
|
||||||
|
bank_remaining = bank_remaining.drop(idx)
|
||||||
|
break
|
||||||
|
|
||||||
|
# 未匹配项
|
||||||
|
bank_only = bank_remaining # 银行有/系统无
|
||||||
|
system_only = system_remaining # 系统有/银行无
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
summary = {
|
||||||
|
'bank_total': bank_df['amount'].sum(),
|
||||||
|
'system_total': system_df['amount'].sum(),
|
||||||
|
'matched_count': len(matched_by_ref) + len(fuzzy_matched),
|
||||||
|
'bank_only_count': len(bank_only),
|
||||||
|
'bank_only_amount': bank_only['amount'].sum() if len(bank_only) > 0 else 0,
|
||||||
|
'system_only_count': len(system_only),
|
||||||
|
'system_only_amount': system_only['amount'].sum() if len(system_only) > 0 else 0,
|
||||||
|
'difference': bank_df['amount'].sum() - system_df['amount'].sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'matched': pd.concat([matched_by_ref, pd.DataFrame(fuzzy_matched)]),
|
||||||
|
'bank_only': bank_only,
|
||||||
|
'system_only': system_only,
|
||||||
|
'summary': summary
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 银行余额调节表
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_bank_reconciliation_report(
|
||||||
|
bank_balance: float,
|
||||||
|
book_balance: float,
|
||||||
|
bank_only: pd.DataFrame,
|
||||||
|
system_only: pd.DataFrame,
|
||||||
|
as_of_date: str
|
||||||
|
) -> str:
|
||||||
|
"""生成银行余额调节表"""
|
||||||
|
|
||||||
|
# 分类未达账项
|
||||||
|
bank_receipts_not_in_book = bank_only[bank_only['amount'] > 0]['amount'].sum()
|
||||||
|
bank_payments_not_in_book = bank_only[bank_only['amount'] < 0]['amount'].sum()
|
||||||
|
book_receipts_not_in_bank = system_only[system_only['amount'] > 0]['amount'].sum()
|
||||||
|
book_payments_not_in_bank = system_only[system_only['amount'] < 0]['amount'].sum()
|
||||||
|
|
||||||
|
report = f"""
|
||||||
|
================================================================================
|
||||||
|
银行存款余额调节表
|
||||||
|
截止日期: {as_of_date}
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
一、银行对账单余额 {bank_balance:>15,.2f}
|
||||||
|
|
||||||
|
加: 企业已收/银行未收 {book_receipts_not_in_bank:>15,.2f}
|
||||||
|
减: 企业已付/银行未付 {abs(book_payments_not_in_bank):>15,.2f}
|
||||||
|
─────────────────
|
||||||
|
调节后余额 {bank_balance + book_receipts_not_in_bank + book_payments_not_in_bank:>15,.2f}
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
二、企业账面余额 {book_balance:>15,.2f}
|
||||||
|
|
||||||
|
加: 银行已收/企业未收 {bank_receipts_not_in_book:>15,.2f}
|
||||||
|
减: 银行已付/企业未付 {abs(bank_payments_not_in_book):>15,.2f}
|
||||||
|
─────────────────
|
||||||
|
调节后余额 {book_balance + bank_receipts_not_in_book + bank_payments_not_in_book:>15,.2f}
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
未达账项明细
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
【银行已收/企业未收】
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(bank_only[bank_only['amount'] > 0]) > 0:
|
||||||
|
for _, row in bank_only[bank_only['amount'] > 0].iterrows():
|
||||||
|
report += f" {row['date'].strftime('%Y-%m-%d')} {row.get('counterparty', ''):<20} {row['amount']:>12,.2f}\n"
|
||||||
|
else:
|
||||||
|
report += " (无)\n"
|
||||||
|
|
||||||
|
report += "\n【银行已付/企业未付】\n"
|
||||||
|
if len(bank_only[bank_only['amount'] < 0]) > 0:
|
||||||
|
for _, row in bank_only[bank_only['amount'] < 0].iterrows():
|
||||||
|
report += f" {row['date'].strftime('%Y-%m-%d')} {row.get('counterparty', ''):<20} {row['amount']:>12,.2f}\n"
|
||||||
|
else:
|
||||||
|
report += " (无)\n"
|
||||||
|
|
||||||
|
report += "\n【企业已收/银行未收】\n"
|
||||||
|
if len(system_only[system_only['amount'] > 0]) > 0:
|
||||||
|
for _, row in system_only[system_only['amount'] > 0].iterrows():
|
||||||
|
report += f" {row['date'].strftime('%Y-%m-%d')} {row.get('counterparty', ''):<20} {row['amount']:>12,.2f}\n"
|
||||||
|
else:
|
||||||
|
report += " (无)\n"
|
||||||
|
|
||||||
|
report += "\n【企业已付/银行未付】\n"
|
||||||
|
if len(system_only[system_only['amount'] < 0]) > 0:
|
||||||
|
for _, row in system_only[system_only['amount'] < 0].iterrows():
|
||||||
|
report += f" {row['date'].strftime('%Y-%m-%d')} {row.get('counterparty', ''):<20} {row['amount']:>12,.2f}\n"
|
||||||
|
else:
|
||||||
|
report += " (无)\n"
|
||||||
|
|
||||||
|
return report
|
||||||
|
```
|
||||||
|
|
||||||
|
### 应收对账
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_ar_statement(
|
||||||
|
customer_name: str,
|
||||||
|
transactions: pd.DataFrame,
|
||||||
|
as_of_date: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
生成客户对账单
|
||||||
|
|
||||||
|
transactions 格式:
|
||||||
|
- date: 日期
|
||||||
|
- doc_type: 单据类型 (发票/收款/退货)
|
||||||
|
- doc_no: 单据编号
|
||||||
|
- debit: 借方(应收增加)
|
||||||
|
- credit: 贷方(应收减少)
|
||||||
|
- memo: 摘要
|
||||||
|
"""
|
||||||
|
|
||||||
|
df = transactions.sort_values('date')
|
||||||
|
|
||||||
|
report = f"""
|
||||||
|
================================================================================
|
||||||
|
客户对账单
|
||||||
|
================================================================================
|
||||||
|
客户名称: {customer_name}
|
||||||
|
对账期间: {df['date'].min().strftime('%Y-%m-%d')} 至 {as_of_date}
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
日期 单据类型 单据编号 借方 贷方 余额
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
|
||||||
|
balance = 0
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
debit = row.get('debit', 0) or 0
|
||||||
|
credit = row.get('credit', 0) or 0
|
||||||
|
balance += debit - credit
|
||||||
|
|
||||||
|
report += f"{row['date'].strftime('%Y-%m-%d')} {row['doc_type']:<8} {row['doc_no']:<16} {debit:>12,.2f} {credit:>12,.2f} {balance:>12,.2f}\n"
|
||||||
|
|
||||||
|
report += f"""
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
合计 {df['debit'].sum():>12,.2f} {df['credit'].sum():>12,.2f} {balance:>12,.2f}
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
期末应收余额: {balance:,.2f}
|
||||||
|
|
||||||
|
请贵司核对以上账目,如有异议请于收到对账单后 7 日内书面告知。
|
||||||
|
如无异议,视为确认。
|
||||||
|
|
||||||
|
对账联系人: _______________
|
||||||
|
联系电话: _______________
|
||||||
|
"""
|
||||||
|
|
||||||
|
return report
|
||||||
|
```
|
||||||
|
|
||||||
|
### 账龄分析
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def aging_analysis(
|
||||||
|
receivables: pd.DataFrame,
|
||||||
|
as_of_date: str,
|
||||||
|
aging_buckets: list = [30, 60, 90, 180, 365]
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
应收账龄分析
|
||||||
|
|
||||||
|
receivables 格式:
|
||||||
|
- customer: 客户
|
||||||
|
- invoice_date: 发票日期
|
||||||
|
- amount: 未收金额
|
||||||
|
"""
|
||||||
|
|
||||||
|
as_of = datetime.strptime(as_of_date, '%Y-%m-%d')
|
||||||
|
|
||||||
|
def get_aging_bucket(invoice_date):
|
||||||
|
days = (as_of - invoice_date).days
|
||||||
|
for i, bucket in enumerate(aging_buckets):
|
||||||
|
if days <= bucket:
|
||||||
|
if i == 0:
|
||||||
|
return f'0-{bucket}天'
|
||||||
|
else:
|
||||||
|
return f'{aging_buckets[i-1]+1}-{bucket}天'
|
||||||
|
return f'{aging_buckets[-1]+1}天以上'
|
||||||
|
|
||||||
|
df = receivables.copy()
|
||||||
|
df['invoice_date'] = pd.to_datetime(df['invoice_date'])
|
||||||
|
df['aging_days'] = (as_of - df['invoice_date']).dt.days
|
||||||
|
df['aging_bucket'] = df['invoice_date'].apply(get_aging_bucket)
|
||||||
|
|
||||||
|
# 按客户和账龄分组汇总
|
||||||
|
summary = df.pivot_table(
|
||||||
|
index='customer',
|
||||||
|
columns='aging_bucket',
|
||||||
|
values='amount',
|
||||||
|
aggfunc='sum',
|
||||||
|
fill_value=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加合计列
|
||||||
|
summary['合计'] = summary.sum(axis=1)
|
||||||
|
|
||||||
|
# 添加合计行
|
||||||
|
summary.loc['合计'] = summary.sum()
|
||||||
|
|
||||||
|
return summary
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对账报告模板
|
||||||
|
|
||||||
|
### Excel 对账报告
|
||||||
|
|
||||||
|
```python
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||||
|
|
||||||
|
def export_reconciliation_to_excel(
|
||||||
|
result: dict,
|
||||||
|
output_path: str,
|
||||||
|
report_date: str
|
||||||
|
):
|
||||||
|
"""导出对账结果到 Excel"""
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
|
||||||
|
# Sheet1: 对账汇总
|
||||||
|
ws_summary = wb.active
|
||||||
|
ws_summary.title = "对账汇总"
|
||||||
|
|
||||||
|
summary = result['summary']
|
||||||
|
ws_summary['A1'] = '对账汇总报告'
|
||||||
|
ws_summary['A1'].font = Font(bold=True, size=14)
|
||||||
|
ws_summary['A2'] = f'对账日期: {report_date}'
|
||||||
|
|
||||||
|
headers = ['项目', '金额', '笔数']
|
||||||
|
for col, header in enumerate(headers, 1):
|
||||||
|
ws_summary.cell(row=4, column=col, value=header)
|
||||||
|
|
||||||
|
data = [
|
||||||
|
('银行流水合计', summary['bank_total'], '-'),
|
||||||
|
('系统记录合计', summary['system_total'], '-'),
|
||||||
|
('已匹配', '-', summary['matched_count']),
|
||||||
|
('银行有/系统无', summary['bank_only_amount'], summary['bank_only_count']),
|
||||||
|
('系统有/银行无', summary['system_only_amount'], summary['system_only_count']),
|
||||||
|
('差异金额', summary['difference'], '-'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for row, (item, amount, count) in enumerate(data, 5):
|
||||||
|
ws_summary.cell(row=row, column=1, value=item)
|
||||||
|
ws_summary.cell(row=row, column=2, value=amount)
|
||||||
|
ws_summary.cell(row=row, column=3, value=count)
|
||||||
|
|
||||||
|
# Sheet2: 银行未达账项
|
||||||
|
ws_bank = wb.create_sheet("银行未达账项")
|
||||||
|
if len(result['bank_only']) > 0:
|
||||||
|
for col, header in enumerate(result['bank_only'].columns, 1):
|
||||||
|
ws_bank.cell(row=1, column=col, value=header)
|
||||||
|
for row, data_row in enumerate(result['bank_only'].values, 2):
|
||||||
|
for col, value in enumerate(data_row, 1):
|
||||||
|
ws_bank.cell(row=row, column=col, value=value)
|
||||||
|
|
||||||
|
# Sheet3: 企业未达账项
|
||||||
|
ws_system = wb.create_sheet("企业未达账项")
|
||||||
|
if len(result['system_only']) > 0:
|
||||||
|
for col, header in enumerate(result['system_only'].columns, 1):
|
||||||
|
ws_system.cell(row=1, column=col, value=header)
|
||||||
|
for row, data_row in enumerate(result['system_only'].values, 2):
|
||||||
|
for col, value in enumerate(data_row, 1):
|
||||||
|
ws_system.cell(row=row, column=col, value=value)
|
||||||
|
|
||||||
|
wb.save(output_path)
|
||||||
|
print(f"对账报告已保存: {output_path}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见对账场景
|
||||||
|
|
||||||
|
### 场景1: 月末银行对账
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. 加载数据
|
||||||
|
bank_df = load_bank_statement('银行流水_202601.xlsx')
|
||||||
|
system_df = load_system_records('收付款记录_202601.xlsx')
|
||||||
|
|
||||||
|
# 2. 执行对账
|
||||||
|
result = reconcile(bank_df, system_df)
|
||||||
|
|
||||||
|
# 3. 查看汇总
|
||||||
|
print(f"已匹配: {result['summary']['matched_count']} 笔")
|
||||||
|
print(f"银行未达: {result['summary']['bank_only_count']} 笔, 金额 {result['summary']['bank_only_amount']:,.2f}")
|
||||||
|
print(f"企业未达: {result['summary']['system_only_count']} 笔, 金额 {result['summary']['system_only_amount']:,.2f}")
|
||||||
|
|
||||||
|
# 4. 生成调节表
|
||||||
|
report = generate_bank_reconciliation_report(
|
||||||
|
bank_balance=150000.00, # 银行对账单余额
|
||||||
|
book_balance=145000.00, # 企业账面余额
|
||||||
|
bank_only=result['bank_only'],
|
||||||
|
system_only=result['system_only'],
|
||||||
|
as_of_date='2026-01-31'
|
||||||
|
)
|
||||||
|
print(report)
|
||||||
|
|
||||||
|
# 5. 导出 Excel
|
||||||
|
export_reconciliation_to_excel(result, '银行对账_202601.xlsx', '2026-01-31')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景2: 客户对账
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 加载客户交易明细
|
||||||
|
ar_transactions = pd.read_excel('应收明细.xlsx')
|
||||||
|
|
||||||
|
# 筛选特定客户
|
||||||
|
customer_data = ar_transactions[ar_transactions['customer'] == '深圳XX公司']
|
||||||
|
|
||||||
|
# 生成对账单
|
||||||
|
statement = generate_ar_statement(
|
||||||
|
customer_name='深圳XX公司',
|
||||||
|
transactions=customer_data,
|
||||||
|
as_of_date='2026-01-31'
|
||||||
|
)
|
||||||
|
print(statement)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景3: 账龄分析
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 加载未收款数据
|
||||||
|
receivables = pd.read_excel('应收账款.xlsx')
|
||||||
|
|
||||||
|
# 账龄分析
|
||||||
|
aging = aging_analysis(
|
||||||
|
receivables=receivables,
|
||||||
|
as_of_date='2026-01-31',
|
||||||
|
aging_buckets=[30, 60, 90, 180, 365]
|
||||||
|
)
|
||||||
|
print(aging)
|
||||||
|
|
||||||
|
# 导出
|
||||||
|
aging.to_excel('账龄分析_202601.xlsx')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景4: 扫描单据OCR识别与对账匹配(诉讼证据核对)
|
||||||
|
|
||||||
|
适用于:承运商/供应商提供扫描件作为诉讼证据,需要与我司对账单进行匹配核对。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
import pytesseract
|
||||||
|
import pandas as pd
|
||||||
|
import re
|
||||||
|
|
||||||
|
def ocr_pdf_extract_orders(pdf_path: str, dpi: int = 150) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
从 PDF 扫描件中 OCR 提取订单信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_path: PDF 文件路径
|
||||||
|
dpi: 图像分辨率,越高识别越准但速度越慢
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame 包含 order_no, page, tc_no 等字段
|
||||||
|
"""
|
||||||
|
print(f"正在转换 PDF...")
|
||||||
|
images = convert_from_path(pdf_path, dpi=dpi)
|
||||||
|
print(f"共 {len(images)} 页,开始 OCR 识别...")
|
||||||
|
|
||||||
|
all_records = []
|
||||||
|
for i, img in enumerate(images, 1):
|
||||||
|
if i % 10 == 0:
|
||||||
|
print(f" 处理进度: {i}/{len(images)}")
|
||||||
|
|
||||||
|
text = pytesseract.image_to_string(img, lang='chi_sim+eng')
|
||||||
|
|
||||||
|
# 提取多种订单号格式(根据实际业务调整)
|
||||||
|
hm_orders = re.findall(r'(HM\d{12,15})(?:-\d+)?', text) # 华住订单
|
||||||
|
hpo_orders = re.findall(r'(HPO-\d{8}-\d+)', text) # 华住采购订单
|
||||||
|
fy_orders = re.findall(r'(FY\d{9,})', text) # 凤悦订单
|
||||||
|
phgco_orders = re.findall(r'(PHGCO\d{9,})', text) # 锦江订单
|
||||||
|
tc_orders = re.findall(r'(TC\d{8,})', text) # 运单号
|
||||||
|
|
||||||
|
# 提取日期
|
||||||
|
dates = re.findall(r'(\d{4}-\d{2}-\d{2})', text)
|
||||||
|
|
||||||
|
all_orders = hm_orders + hpo_orders + fy_orders + phgco_orders
|
||||||
|
for order in all_orders:
|
||||||
|
all_records.append({
|
||||||
|
'order_no': order,
|
||||||
|
'page': i,
|
||||||
|
'tc_no': tc_orders[0] if tc_orders else None,
|
||||||
|
'date': dates[0] if dates else None,
|
||||||
|
'raw_text': text[:500] # 保留部分原文用于核查
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(all_records)
|
||||||
|
df = df.drop_duplicates(subset=['order_no'])
|
||||||
|
print(f"提取完成,共 {len(df)} 条唯一订单")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def extract_base_order_no(order_no: str) -> str:
|
||||||
|
"""
|
||||||
|
提取基础订单号(移除后缀如 -1, -2)
|
||||||
|
|
||||||
|
例如: HM202505227320-1 -> HM202505227320
|
||||||
|
"""
|
||||||
|
if pd.isna(order_no):
|
||||||
|
return None
|
||||||
|
order_str = str(order_no)
|
||||||
|
match = re.match(r'(HM\d{12,15}|HPO-\d{8}-\d+|FY\d{9,}|PHGCO\d{9,})', order_str)
|
||||||
|
return match.group(1) if match else order_str
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_scanned_with_excel(
|
||||||
|
scanned_df: pd.DataFrame,
|
||||||
|
excel_path: str,
|
||||||
|
main_sheet: str,
|
||||||
|
pending_sheet: str = None,
|
||||||
|
order_column: str = '华住/锦江订单号'
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
扫描单据与 Excel 对账单匹配
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scanned_df: OCR 提取的订单 DataFrame
|
||||||
|
excel_path: Excel 对账单路径
|
||||||
|
main_sheet: 主数据 Sheet 名称
|
||||||
|
pending_sheet: 待结算/次月结 Sheet 名称(可选)
|
||||||
|
order_column: 订单号列名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
'matched_main': 在主表中匹配的订单,
|
||||||
|
'matched_pending': 在待结算表中匹配的订单,
|
||||||
|
'not_found': 未找到的订单,
|
||||||
|
'summary': 汇总统计
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 加载主表
|
||||||
|
df_main = pd.read_excel(excel_path, sheet_name=main_sheet)
|
||||||
|
# 处理表头(如果第一行是表头)
|
||||||
|
if df_main.iloc[0].astype(str).str.contains(order_column).any():
|
||||||
|
df_main.columns = df_main.iloc[0]
|
||||||
|
df_main = df_main.iloc[1:].reset_index(drop=True)
|
||||||
|
|
||||||
|
# 提取基础订单号
|
||||||
|
main_orders = set(df_main[order_column].apply(extract_base_order_no).dropna())
|
||||||
|
|
||||||
|
# 加载待结算表(如有)
|
||||||
|
pending_orders = set()
|
||||||
|
df_pending = None
|
||||||
|
if pending_sheet:
|
||||||
|
try:
|
||||||
|
df_pending = pd.read_excel(excel_path, sheet_name=pending_sheet)
|
||||||
|
pending_orders = set(df_pending[order_column].apply(extract_base_order_no).dropna())
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
all_excel_orders = main_orders | pending_orders
|
||||||
|
|
||||||
|
# 扫描件订单号
|
||||||
|
scan_orders = set(scanned_df['order_no'].apply(extract_base_order_no).dropna())
|
||||||
|
|
||||||
|
# 匹配分析
|
||||||
|
matched_main = scan_orders & main_orders
|
||||||
|
matched_pending = scan_orders & pending_orders
|
||||||
|
not_found = scan_orders - all_excel_orders
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
summary = {
|
||||||
|
'scan_total': len(scan_orders),
|
||||||
|
'matched_main_count': len(matched_main),
|
||||||
|
'matched_pending_count': len(matched_pending),
|
||||||
|
'not_found_count': len(not_found),
|
||||||
|
'match_rate': (len(matched_main) + len(matched_pending)) / len(scan_orders) * 100 if scan_orders else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'matched_main': matched_main,
|
||||||
|
'matched_pending': matched_pending,
|
||||||
|
'not_found': not_found,
|
||||||
|
'summary': summary,
|
||||||
|
'df_main': df_main,
|
||||||
|
'df_pending': df_pending
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_litigation_reconciliation_report(
|
||||||
|
result: dict,
|
||||||
|
scanned_df: pd.DataFrame,
|
||||||
|
output_path: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
生成诉讼对账分析报告
|
||||||
|
"""
|
||||||
|
summary = result['summary']
|
||||||
|
|
||||||
|
with pd.ExcelWriter(output_path, engine='openpyxl') as writer:
|
||||||
|
# Sheet1: 汇总
|
||||||
|
summary_df = pd.DataFrame({
|
||||||
|
'项目': [
|
||||||
|
'扫描件订单数',
|
||||||
|
'在主对账单中找到',
|
||||||
|
'在待结算表中找到',
|
||||||
|
'完全未找到',
|
||||||
|
'匹配率'
|
||||||
|
],
|
||||||
|
'数值': [
|
||||||
|
summary['scan_total'],
|
||||||
|
summary['matched_main_count'],
|
||||||
|
summary['matched_pending_count'],
|
||||||
|
summary['not_found_count'],
|
||||||
|
f"{summary['match_rate']:.1f}%"
|
||||||
|
]
|
||||||
|
})
|
||||||
|
summary_df.to_excel(writer, sheet_name='对账汇总', index=False)
|
||||||
|
|
||||||
|
# Sheet2: 未找到的订单
|
||||||
|
if result['not_found']:
|
||||||
|
not_found_df = scanned_df[
|
||||||
|
scanned_df['order_no'].apply(extract_base_order_no).isin(result['not_found'])
|
||||||
|
][['order_no', 'page', 'tc_no', 'date']]
|
||||||
|
not_found_df.columns = ['订单号', 'PDF页码', '运单号', '日期']
|
||||||
|
not_found_df.to_excel(writer, sheet_name='未找到订单', index=False)
|
||||||
|
|
||||||
|
# Sheet3: 待结算订单详情
|
||||||
|
if result['matched_pending'] and result['df_pending'] is not None:
|
||||||
|
pending_detail = result['df_pending'][
|
||||||
|
result['df_pending']['华住/锦江订单号'].apply(extract_base_order_no).isin(result['matched_pending'])
|
||||||
|
]
|
||||||
|
pending_detail.to_excel(writer, sheet_name='待结算订单', index=False)
|
||||||
|
|
||||||
|
print(f"报告已保存: {output_path}")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. 从 PDF 扫描件提取订单
|
||||||
|
pdf_path = "承运商证据_6月业务.pdf"
|
||||||
|
scanned_df = ocr_pdf_extract_orders(pdf_path, dpi=150)
|
||||||
|
|
||||||
|
# 2. 与 Excel 对账单匹配
|
||||||
|
result = reconcile_scanned_with_excel(
|
||||||
|
scanned_df=scanned_df,
|
||||||
|
excel_path="对账单_2025年6月.xlsx",
|
||||||
|
main_sheet="2025.6",
|
||||||
|
pending_sheet="次月结",
|
||||||
|
order_column="华住/锦江订单号"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 查看匹配结果
|
||||||
|
print(f"匹配率: {result['summary']['match_rate']:.1f}%")
|
||||||
|
print(f"未找到: {result['summary']['not_found_count']} 条")
|
||||||
|
|
||||||
|
if result['not_found']:
|
||||||
|
print("\n未找到的订单:")
|
||||||
|
for order in result['not_found']:
|
||||||
|
print(f" {order}")
|
||||||
|
|
||||||
|
# 4. 生成报告
|
||||||
|
generate_litigation_reconciliation_report(
|
||||||
|
result=result,
|
||||||
|
scanned_df=scanned_df,
|
||||||
|
output_path="诉讼对账分析报告.xlsx"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OCR 识别注意事项
|
||||||
|
|
||||||
|
1. **环境依赖**:
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install tesseract tesseract-lang poppler
|
||||||
|
pip install pdf2image pytesseract pandas openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **识别精度**:
|
||||||
|
- DPI 150 适合快速扫描,DPI 200-250 适合精确识别
|
||||||
|
- 中文识别需要 `chi_sim` 语言包
|
||||||
|
|
||||||
|
3. **订单号格式差异**:
|
||||||
|
- PDF 中可能是 `HM202505227320`
|
||||||
|
- Excel 中可能是 `HM202505227320-1`、`HM202505227320-2`(带后缀)
|
||||||
|
- 匹配时应使用基础订单号
|
||||||
|
|
||||||
|
4. **常见问题**:
|
||||||
|
- 空白页(签收单背面)会被自动跳过
|
||||||
|
- OCR 可能误识别字符(如 5→S, 0→O),建议人工抽查
|
||||||
|
- 大 PDF 处理较慢,可先用低 DPI 预览
|
||||||
|
|
||||||
|
5. **"次月结"/"待结算" 订单**:
|
||||||
|
- 这类订单通常标记为"没有报价"、"未建单"等
|
||||||
|
- 虽然在系统中有记录,但可能未计入正式结算金额
|
||||||
|
- 诉讼中需特别关注这类订单的状态
|
||||||
|
|
||||||
|
6. **多承运商订单归属校验(重要风险点)**:
|
||||||
|
- 承运商可能将其他公司的订单混入证据,企图多收运费
|
||||||
|
- 必须检查PDF订单是否出现在其他承运商的对账单中
|
||||||
|
- 如果订单同时出现在多个承运商对账单中,则为异常订单
|
||||||
|
|
||||||
|
#### 多承运商订单归属校验
|
||||||
|
|
||||||
|
```python
|
||||||
|
def check_order_ownership(
|
||||||
|
scanned_df: pd.DataFrame,
|
||||||
|
my_company_excel: str,
|
||||||
|
other_carrier_excels: list[str],
|
||||||
|
order_column: str = '华住/锦江订单号'
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
检查订单归属,识别是否有其他承运商的订单被混入
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scanned_df: OCR 提取的订单 DataFrame
|
||||||
|
my_company_excel: 我司对账单路径
|
||||||
|
other_carrier_excels: 其他承运商对账单路径列表
|
||||||
|
order_column: 订单号列名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
'my_orders': 属于我司的订单,
|
||||||
|
'other_carrier_orders': 属于其他承运商的订单(异常),
|
||||||
|
'disputed_orders': 同时出现在多处的争议订单,
|
||||||
|
'summary': 汇总
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 提取PDF订单
|
||||||
|
scan_orders = set(scanned_df['order_no'].apply(extract_base_order_no).dropna())
|
||||||
|
|
||||||
|
# 加载我司对账单订单
|
||||||
|
my_orders = set()
|
||||||
|
try:
|
||||||
|
xl = pd.ExcelFile(my_company_excel)
|
||||||
|
for sheet in xl.sheet_names:
|
||||||
|
df = pd.read_excel(my_company_excel, sheet_name=sheet)
|
||||||
|
for col in df.columns:
|
||||||
|
if '订单' in str(col):
|
||||||
|
orders = df[col].apply(extract_base_order_no).dropna()
|
||||||
|
my_orders.update(orders)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"加载我司对账单失败: {e}")
|
||||||
|
|
||||||
|
# 加载其他承运商对账单订单
|
||||||
|
other_orders = {} # {carrier_name: set of orders}
|
||||||
|
for excel_path in other_carrier_excels:
|
||||||
|
carrier_name = excel_path.split('/')[-1]
|
||||||
|
carrier_orders = set()
|
||||||
|
try:
|
||||||
|
xl = pd.ExcelFile(excel_path)
|
||||||
|
for sheet in xl.sheet_names:
|
||||||
|
df = pd.read_excel(excel_path, sheet_name=sheet)
|
||||||
|
for col in df.columns:
|
||||||
|
if '订单' in str(col):
|
||||||
|
orders = df[col].apply(extract_base_order_no).dropna()
|
||||||
|
carrier_orders.update(orders)
|
||||||
|
other_orders[carrier_name] = carrier_orders
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 分析订单归属
|
||||||
|
all_other_orders = set()
|
||||||
|
for orders in other_orders.values():
|
||||||
|
all_other_orders.update(orders)
|
||||||
|
|
||||||
|
# 分类
|
||||||
|
only_mine = scan_orders & my_orders - all_other_orders # 仅在我司
|
||||||
|
only_others = scan_orders & all_other_orders - my_orders # 仅在其他承运商(异常!)
|
||||||
|
in_both = scan_orders & my_orders & all_other_orders # 同时出现(争议)
|
||||||
|
nowhere = scan_orders - my_orders - all_other_orders # 哪里都没有
|
||||||
|
|
||||||
|
# 识别异常订单属于哪个承运商
|
||||||
|
abnormal_details = []
|
||||||
|
for order in only_others:
|
||||||
|
for carrier, orders in other_orders.items():
|
||||||
|
if order in orders:
|
||||||
|
page = scanned_df[scanned_df['order_no'].apply(extract_base_order_no) == order]['page'].values
|
||||||
|
abnormal_details.append({
|
||||||
|
'order_no': order,
|
||||||
|
'belongs_to': carrier,
|
||||||
|
'pdf_page': page[0] if len(page) > 0 else None
|
||||||
|
})
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
'scan_total': len(scan_orders),
|
||||||
|
'my_orders_count': len(only_mine),
|
||||||
|
'other_carrier_count': len(only_others), # 异常订单数
|
||||||
|
'disputed_count': len(in_both),
|
||||||
|
'not_found_count': len(nowhere)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'my_orders': only_mine,
|
||||||
|
'other_carrier_orders': only_others,
|
||||||
|
'abnormal_details': abnormal_details,
|
||||||
|
'disputed_orders': in_both,
|
||||||
|
'not_found': nowhere,
|
||||||
|
'summary': summary
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用示例(多承运商校验)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 检查订单归属
|
||||||
|
result = check_order_ownership(
|
||||||
|
scanned_df=scanned_df,
|
||||||
|
my_company_excel="智慧云彩对账单_2025年6月.xlsx",
|
||||||
|
other_carrier_excels=[
|
||||||
|
"友利速运对账单_2025年6月.xlsx",
|
||||||
|
"其他承运商对账单.xlsx"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否有异常订单
|
||||||
|
if result['summary']['other_carrier_count'] > 0:
|
||||||
|
print(f"⚠️ 发现 {result['summary']['other_carrier_count']} 个异常订单!")
|
||||||
|
print("这些订单属于其他承运商,不应由我司支付:")
|
||||||
|
for item in result['abnormal_details']:
|
||||||
|
print(f" {item['order_no']} -> 属于 {item['belongs_to']} (PDF第{item['pdf_page']}页)")
|
||||||
|
else:
|
||||||
|
print("✓ 未发现异常订单,所有订单归属正确")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **数据格式统一**: 确保日期、金额格式一致后再对账
|
||||||
|
2. **匹配规则灵活**: 先精确匹配流水号,再模糊匹配金额+日期
|
||||||
|
3. **差异原因分析**: 对未达账项要逐笔核实原因
|
||||||
|
4. **定期对账**: 建议至少每月进行一次银行对账
|
||||||
|
5. **凭证留存**: 对账报告需打印签字存档
|
||||||
|
6. **OCR 核对**: 扫描件 OCR 后应人工抽查,确保识别准确
|
||||||
|
7. **多承运商校验**: 诉讼场景下必须检查订单是否属于其他承运商,防止对方混入他人订单骗取运费
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与其他技能协作
|
||||||
|
|
||||||
|
- **data-excel**: 处理复杂的 Excel 数据转换
|
||||||
|
- **feishu**: 将对账报告保存到飞书云文档
|
||||||
|
- **siyuan**: 记录对账工作日志到思源笔记
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "finishing-a-development-branch-plugin",
|
||||||
|
"description": "Plugin for finishing-a-development-branch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
104
plugins/finishing-a-development-branch-plugin/skills/SKILL.md
Normal file
104
plugins/finishing-a-development-branch-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: finishing-a-development-branch
|
||||||
|
description: Use when implementation is complete and all tests pass - verifies and creates PR
|
||||||
|
---
|
||||||
|
|
||||||
|
# Finishing a Development Branch
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Verify tests pass, then push and create PR.
|
||||||
|
|
||||||
|
**Core principle:** Verify tests → Create PR → Done.
|
||||||
|
|
||||||
|
**Announce at start:** "I'm using the finishing-a-development-branch skill to complete this work."
|
||||||
|
|
||||||
|
## The Process
|
||||||
|
|
||||||
|
### Step 1: Verify Tests
|
||||||
|
|
||||||
|
**Before creating PR, verify tests pass:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run project's test suite
|
||||||
|
npm test / cargo test / pytest / go test ./... / mvn test
|
||||||
|
```
|
||||||
|
|
||||||
|
**If tests fail:**
|
||||||
|
```
|
||||||
|
Tests failing (<N> failures). Must fix before completing:
|
||||||
|
|
||||||
|
[Show failures]
|
||||||
|
|
||||||
|
Cannot proceed with PR until tests pass.
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop. Fix tests first.
|
||||||
|
|
||||||
|
**If tests pass:** Continue to Step 2.
|
||||||
|
|
||||||
|
### Step 2: Push and Create PR
|
||||||
|
|
||||||
|
Use the `/pr create` command which will:
|
||||||
|
1. **Check for existing PR first** - avoids duplicates
|
||||||
|
2. If PR exists: Report existing PR URL and skip
|
||||||
|
3. If no PR: Analyze commits, generate title/description, push, create PR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/pr create
|
||||||
|
```
|
||||||
|
|
||||||
|
**Duplicate prevention:** The `/pr create` command checks for existing open PRs on the current branch before creating a new one.
|
||||||
|
|
||||||
|
Report the PR URL when complete (whether existing or newly created).
|
||||||
|
|
||||||
|
### Step 3: Cleanup Worktree (if applicable)
|
||||||
|
|
||||||
|
Check if working in a worktree:
|
||||||
|
```bash
|
||||||
|
git worktree list | grep $(git branch --show-current)
|
||||||
|
```
|
||||||
|
|
||||||
|
If yes, ask user:
|
||||||
|
```
|
||||||
|
Worktree at <path>. Remove it now? (y/n)
|
||||||
|
```
|
||||||
|
|
||||||
|
If confirmed:
|
||||||
|
```bash
|
||||||
|
git worktree remove <worktree-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
Tests Pass?
|
||||||
|
↓ yes
|
||||||
|
/pr create
|
||||||
|
↓
|
||||||
|
PR URL returned
|
||||||
|
↓
|
||||||
|
Cleanup worktree (optional)
|
||||||
|
↓
|
||||||
|
Done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Red Flags
|
||||||
|
|
||||||
|
**Never:**
|
||||||
|
- Create PR with failing tests
|
||||||
|
- Skip test verification
|
||||||
|
- Force-push without explicit request
|
||||||
|
|
||||||
|
**Always:**
|
||||||
|
- Verify tests before creating PR
|
||||||
|
- Use `/pr create` for consistent PR format
|
||||||
|
- Report the PR URL
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
**Called by:**
|
||||||
|
- **executing-plans** (Step 6) - After all batches complete
|
||||||
|
|
||||||
|
**Uses:**
|
||||||
|
- **/pr create** - For pushing and PR creation
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-design-plugin",
|
||||||
|
"description": "Plugin for frontend-design",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
695
plugins/frontend-design-plugin/skills/SKILL.md
Normal file
695
plugins/frontend-design-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
---
|
||||||
|
name: frontend-design
|
||||||
|
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||||
|
arguments: [component|page|storybook] <description>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend Design 前端设计技能
|
||||||
|
|
||||||
|
创建高质量、有设计感的前端界面和组件,支持 Storybook 组件开发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 命令格式
|
||||||
|
|
||||||
|
| 命令 | 功能 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/frontend-design component <描述>` | 创建 React/Vue 组件 | `/frontend-design component 产品卡片` |
|
||||||
|
| `/frontend-design page <描述>` | 创建完整页面 | `/frontend-design page 登录页` |
|
||||||
|
| `/frontend-design storybook <描述>` | 创建带 Storybook 的组件 | `/frontend-design storybook 按钮组件` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
### 1. 设计思维先行
|
||||||
|
|
||||||
|
在编码前,明确以下问题:
|
||||||
|
|
||||||
|
- **目的**:这个界面解决什么问题?谁在使用?
|
||||||
|
- **调性**:选择一个明确的美学方向
|
||||||
|
- **差异化**:什么让这个设计令人难忘?
|
||||||
|
|
||||||
|
### 2. 美学方向选择
|
||||||
|
|
||||||
|
| 风格 | 特点 | 适用场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 极简主义 | 大量留白、精炼元素 | 工具类、专业平台 |
|
||||||
|
| 现代商务 | 清晰层次、专业配色 | 企业官网、B2B |
|
||||||
|
| 活力年轻 | 鲜艳色彩、动感动画 | 消费品、社交 |
|
||||||
|
| 奢华精致 | 深色调、金属质感 | 高端品牌、金融 |
|
||||||
|
| 自然有机 | 柔和曲线、自然色系 | 健康、环保 |
|
||||||
|
| 复古怀旧 | 经典字体、做旧质感 | 文化、艺术 |
|
||||||
|
| 未来科技 | 渐变、玻璃拟态 | 科技、创新 |
|
||||||
|
|
||||||
|
### 3. 避免的设计陷阱
|
||||||
|
|
||||||
|
**禁止使用**:
|
||||||
|
- 过度使用的字体:Inter、Roboto、Arial
|
||||||
|
- 陈词滥调的配色:紫色渐变白底
|
||||||
|
- 千篇一律的布局
|
||||||
|
- 缺乏个性的通用组件
|
||||||
|
|
||||||
|
**应该追求**:
|
||||||
|
- 独特的字体组合
|
||||||
|
- 有意图的配色方案
|
||||||
|
- 打破常规的布局
|
||||||
|
- 有记忆点的细节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storybook 组件开发
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── Button/
|
||||||
|
│ │ ├── Button.tsx
|
||||||
|
│ │ ├── Button.stories.tsx
|
||||||
|
│ │ ├── Button.module.css
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── Card/
|
||||||
|
│ │ ├── Card.tsx
|
||||||
|
│ │ ├── Card.stories.tsx
|
||||||
|
│ │ ├── Card.module.css
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ └── index.ts
|
||||||
|
├── styles/
|
||||||
|
│ ├── variables.css
|
||||||
|
│ ├── typography.css
|
||||||
|
│ └── animations.css
|
||||||
|
└── .storybook/
|
||||||
|
├── main.ts
|
||||||
|
└── preview.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件模板
|
||||||
|
|
||||||
|
#### 1. 组件文件 (Component.tsx)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './Component.module.css';
|
||||||
|
|
||||||
|
export interface ComponentProps {
|
||||||
|
/** 组件变体 */
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline';
|
||||||
|
/** 尺寸 */
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
/** 是否禁用 */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 子元素 */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** 点击事件 */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Component: React.FC<ComponentProps> = ({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.component} ${styles[variant]} ${styles[size]}`}
|
||||||
|
data-disabled={disabled}
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Storybook Stories (Component.stories.tsx)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Component } from './Component';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Component> = {
|
||||||
|
title: 'Components/Component',
|
||||||
|
component: Component,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: '组件描述文档',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
variant: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['primary', 'secondary', 'outline'],
|
||||||
|
description: '组件变体样式',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
control: 'radio',
|
||||||
|
options: ['sm', 'md', 'lg'],
|
||||||
|
description: '组件尺寸',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: '是否禁用',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Component>;
|
||||||
|
|
||||||
|
/** 默认状态 */
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
children: '默认组件',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 主要变体 */
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {
|
||||||
|
variant: 'primary',
|
||||||
|
children: '主要按钮',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 次要变体 */
|
||||||
|
export const Secondary: Story = {
|
||||||
|
args: {
|
||||||
|
variant: 'secondary',
|
||||||
|
children: '次要按钮',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 不同尺寸 */
|
||||||
|
export const Sizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
|
<Component size="sm">小号</Component>
|
||||||
|
<Component size="md">中号</Component>
|
||||||
|
<Component size="lg">大号</Component>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 禁用状态 */
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
disabled: true,
|
||||||
|
children: '禁用状态',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 样式文件 (Component.module.css)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.component {
|
||||||
|
/* 基础样式 */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 变体 */
|
||||||
|
.primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px var(--color-primary-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 尺寸 */
|
||||||
|
.sm {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态 */
|
||||||
|
[data-disabled="true"] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计系统变量
|
||||||
|
|
||||||
|
### CSS 变量模板
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* 颜色 */
|
||||||
|
--color-primary: #0066ff;
|
||||||
|
--color-primary-dark: #0052cc;
|
||||||
|
--color-primary-light: #4d94ff;
|
||||||
|
--color-primary-shadow: rgba(0, 102, 255, 0.25);
|
||||||
|
|
||||||
|
--color-secondary: #f0f4f8;
|
||||||
|
--color-accent: #ff6b35;
|
||||||
|
|
||||||
|
--color-text: #1a1a2e;
|
||||||
|
--color-text-muted: #64748b;
|
||||||
|
--color-text-inverse: #ffffff;
|
||||||
|
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-surface: #f8fafc;
|
||||||
|
--color-border: #e2e8f0;
|
||||||
|
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-error: #ef4444;
|
||||||
|
|
||||||
|
/* 字体 */
|
||||||
|
--font-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
|
--font-display: 'Clash Display', var(--font-sans);
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* 字号 */
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-lg: 1.125rem;
|
||||||
|
--text-xl: 1.25rem;
|
||||||
|
--text-2xl: 1.5rem;
|
||||||
|
--text-3xl: 2rem;
|
||||||
|
--text-4xl: 2.5rem;
|
||||||
|
|
||||||
|
/* 间距 */
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-6: 1.5rem;
|
||||||
|
--space-8: 2rem;
|
||||||
|
--space-12: 3rem;
|
||||||
|
--space-16: 4rem;
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 1rem;
|
||||||
|
--radius-xl: 1.5rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
/* 动画 */
|
||||||
|
--duration-fast: 150ms;
|
||||||
|
--duration-normal: 300ms;
|
||||||
|
--duration-slow: 500ms;
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色主题 */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-text: #f1f5f9;
|
||||||
|
--color-text-muted: #94a3b8;
|
||||||
|
--color-background: #0f172a;
|
||||||
|
--color-surface: #1e293b;
|
||||||
|
--color-border: #334155;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用组件示例
|
||||||
|
|
||||||
|
### 1. 产品卡片 (ProductCard)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ProductCard.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './ProductCard.module.css';
|
||||||
|
|
||||||
|
export interface ProductCardProps {
|
||||||
|
image: string;
|
||||||
|
title: string;
|
||||||
|
location: string;
|
||||||
|
rating: number;
|
||||||
|
reviewCount: number;
|
||||||
|
price: number;
|
||||||
|
originalPrice?: number;
|
||||||
|
tags?: string[];
|
||||||
|
onAddToCart?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductCard: React.FC<ProductCardProps> = ({
|
||||||
|
image,
|
||||||
|
title,
|
||||||
|
location,
|
||||||
|
rating,
|
||||||
|
reviewCount,
|
||||||
|
price,
|
||||||
|
originalPrice,
|
||||||
|
tags = [],
|
||||||
|
onAddToCart,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<article className={styles.card}>
|
||||||
|
<div className={styles.imageWrapper}>
|
||||||
|
<img src={image} alt={title} className={styles.image} />
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className={styles.tags}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag} className={styles.tag} data-tag={tag}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h3 className={styles.title}>{title}</h3>
|
||||||
|
<p className={styles.location}>📍 {location}</p>
|
||||||
|
|
||||||
|
<div className={styles.rating}>
|
||||||
|
<span className={styles.stars}>⭐ {rating.toFixed(1)}</span>
|
||||||
|
<span className={styles.reviewCount}>({reviewCount}条评价)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.priceRow}>
|
||||||
|
<div className={styles.price}>
|
||||||
|
<span className={styles.currency}>¥</span>
|
||||||
|
<span className={styles.amount}>{price}</span>
|
||||||
|
<span className={styles.suffix}>起</span>
|
||||||
|
</div>
|
||||||
|
{originalPrice && (
|
||||||
|
<span className={styles.originalPrice}>¥{originalPrice}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={styles.addButton} onClick={onAddToCart}>
|
||||||
|
加入购物车
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ProductCard.stories.tsx
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { ProductCard } from './ProductCard';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ProductCard> = {
|
||||||
|
title: 'Components/ProductCard',
|
||||||
|
component: ProductCard,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
backgrounds: {
|
||||||
|
default: 'light',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ProductCard>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
image: 'https://images.unsplash.com/photo-1494947665470-20322015e3a8',
|
||||||
|
title: '袋鼠岛一日游',
|
||||||
|
location: '阿德莱德出发',
|
||||||
|
rating: 4.8,
|
||||||
|
reviewCount: 126,
|
||||||
|
price: 389,
|
||||||
|
tags: ['热卖', '含午餐'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithDiscount: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
originalPrice: 499,
|
||||||
|
tags: ['特惠', '限时'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Grid: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 300px)',
|
||||||
|
gap: '1.5rem'
|
||||||
|
}}>
|
||||||
|
<ProductCard
|
||||||
|
image="https://images.unsplash.com/photo-1494947665470-20322015e3a8"
|
||||||
|
title="袋鼠岛一日游"
|
||||||
|
location="阿德莱德出发"
|
||||||
|
rating={4.8}
|
||||||
|
reviewCount={126}
|
||||||
|
price={389}
|
||||||
|
tags={['热卖']}
|
||||||
|
/>
|
||||||
|
<ProductCard
|
||||||
|
image="https://images.unsplash.com/photo-1506905925346-21bda4d32df4"
|
||||||
|
title="巴罗莎谷酒庄之旅"
|
||||||
|
location="阿德莱德出发"
|
||||||
|
rating={4.9}
|
||||||
|
reviewCount={89}
|
||||||
|
price={299}
|
||||||
|
originalPrice={399}
|
||||||
|
tags={['特惠', '含品酒']}
|
||||||
|
/>
|
||||||
|
<ProductCard
|
||||||
|
image="https://images.unsplash.com/photo-1540202403-b7abd6747a18"
|
||||||
|
title="海豚巡航体验"
|
||||||
|
location="格雷尔海滩"
|
||||||
|
rating={4.7}
|
||||||
|
reviewCount={234}
|
||||||
|
price={159}
|
||||||
|
tags={['亲子']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 按钮组件 (Button)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Button.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './Button.module.css';
|
||||||
|
|
||||||
|
export interface ButtonProps {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
fullWidth?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = false,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
${styles.button}
|
||||||
|
${styles[variant]}
|
||||||
|
${styles[size]}
|
||||||
|
${fullWidth ? styles.fullWidth : ''}
|
||||||
|
`}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className={styles.spinner} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{leftIcon && <span className={styles.icon}>{leftIcon}</span>}
|
||||||
|
<span>{children}</span>
|
||||||
|
{rightIcon && <span className={styles.icon}>{rightIcon}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storybook 配置
|
||||||
|
|
||||||
|
### .storybook/main.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/addon-interactions',
|
||||||
|
'@storybook/addon-a11y',
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/react-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
### .storybook/preview.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Preview } from '@storybook/react';
|
||||||
|
import '../src/styles/variables.css';
|
||||||
|
import '../src/styles/typography.css';
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backgrounds: {
|
||||||
|
default: 'light',
|
||||||
|
values: [
|
||||||
|
{ name: 'light', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#f8fafc' },
|
||||||
|
{ name: 'dark', value: '#0f172a' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globalTypes: {
|
||||||
|
theme: {
|
||||||
|
description: 'Global theme for components',
|
||||||
|
defaultValue: 'light',
|
||||||
|
toolbar: {
|
||||||
|
title: 'Theme',
|
||||||
|
icon: 'circlehollow',
|
||||||
|
items: ['light', 'dark'],
|
||||||
|
dynamicTitle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速启动命令
|
||||||
|
|
||||||
|
### 创建新组件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建组件目录
|
||||||
|
mkdir -p src/components/ComponentName
|
||||||
|
|
||||||
|
# 创建文件
|
||||||
|
touch src/components/ComponentName/{ComponentName.tsx,ComponentName.stories.tsx,ComponentName.module.css,index.ts}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安装 Storybook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 初始化 Storybook
|
||||||
|
npx storybook@latest init
|
||||||
|
|
||||||
|
# 安装额外插件
|
||||||
|
npm install -D @storybook/addon-a11y @storybook/addon-interactions
|
||||||
|
|
||||||
|
# 启动 Storybook
|
||||||
|
npm run storybook
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计检查清单
|
||||||
|
|
||||||
|
### 组件质量检查
|
||||||
|
|
||||||
|
- [ ] Props 接口定义完整,带 JSDoc 注释
|
||||||
|
- [ ] 支持必要的变体(variant)和尺寸(size)
|
||||||
|
- [ ] 处理禁用和加载状态
|
||||||
|
- [ ] 支持自定义 className
|
||||||
|
- [ ] 键盘可访问性
|
||||||
|
- [ ] 屏幕阅读器友好
|
||||||
|
|
||||||
|
### Storybook 质量检查
|
||||||
|
|
||||||
|
- [ ] 所有变体都有对应 Story
|
||||||
|
- [ ] argTypes 配置完整
|
||||||
|
- [ ] 包含组件文档描述
|
||||||
|
- [ ] 交互状态可测试
|
||||||
|
- [ ] 响应式展示
|
||||||
|
|
||||||
|
### 视觉质量检查
|
||||||
|
|
||||||
|
- [ ] 字体选择有特色
|
||||||
|
- [ ] 配色方案协调
|
||||||
|
- [ ] 动画流畅自然
|
||||||
|
- [ ] 间距一致
|
||||||
|
- [ ] 暗色主题支持
|
||||||
8
plugins/gitea-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/gitea-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "gitea-plugin",
|
||||||
|
"description": "Gitea 代码托管与 CI/CD 管理。用于 Gitea Actions workflow 管理、Runner 管理、PR 操作、仓库配置。",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
211
plugins/gitea-plugin/scripts/gitea-runs
Executable file
211
plugins/gitea-plugin/scripts/gitea-runs
Executable file
@@ -0,0 +1,211 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# gitea-runs — Gitea Actions CLI helper
|
||||||
|
# Usage:
|
||||||
|
# gitea-runs List recent runs
|
||||||
|
# gitea-runs list [limit] List recent runs (default 10)
|
||||||
|
# gitea-runs view <run_number> View run details & jobs
|
||||||
|
# gitea-runs open [run_number] Open run in browser
|
||||||
|
# gitea-runs workflows List workflows
|
||||||
|
# gitea-runs dispatch <wf> [ref] Trigger a workflow dispatch
|
||||||
|
# gitea-runs help Show this help
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Config from tea CLI
|
||||||
|
TEA_CONFIG="${XDG_CONFIG_HOME:-$HOME/Library/Application Support}/tea/config.yml"
|
||||||
|
if [ ! -f "$TEA_CONFIG" ]; then
|
||||||
|
TEA_CONFIG="$HOME/.config/tea/config.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse tea config (nested under logins)
|
||||||
|
GITEA_URL=$(grep 'url:' "$TEA_CONFIG" | head -1 | awk '{print $NF}')
|
||||||
|
GITEA_TOKEN=$(grep 'token:' "$TEA_CONFIG" | head -1 | awk '{print $NF}')
|
||||||
|
|
||||||
|
# Detect repo from git remote
|
||||||
|
REPO=$(git remote get-url origin 2>/dev/null | sed 's|.*gitea.pipexerp.com[:/]*||;s|\.git$||;s|^10022/||')
|
||||||
|
if [ -z "$REPO" ]; then
|
||||||
|
echo "Error: not in a git repo or remote not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
API="$GITEA_URL/api/v1"
|
||||||
|
AUTH="Authorization: token $GITEA_TOKEN"
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
cmd_dispatch() {
|
||||||
|
local workflow="${1:-}"
|
||||||
|
local ref="${2:-main}"
|
||||||
|
if [ -z "$workflow" ]; then
|
||||||
|
echo "Usage: gitea-runs dispatch <workflow> [ref]"
|
||||||
|
echo ""
|
||||||
|
echo "Available workflows:"
|
||||||
|
curl -s -H "$AUTH" "$API/repos/$REPO/actions/workflows" 2>/dev/null \
|
||||||
|
| python3 -c "
|
||||||
|
import json, sys
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for w in data.get('workflows', []):
|
||||||
|
print(f\" {w['id']:30s} {w['name']}\")
|
||||||
|
" 2>/dev/null
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "$AUTH" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST "$API/repos/$REPO/actions/workflows/$workflow/dispatches" \
|
||||||
|
-d "{\"ref\":\"$ref\"}" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$http_code" = "204" ]; then
|
||||||
|
echo -e "${GREEN}✓${NC} Dispatched workflow: $workflow (ref: $ref)"
|
||||||
|
echo " View: $GITEA_URL/$REPO/actions"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗${NC} Failed to dispatch (HTTP $http_code)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_workflows() {
|
||||||
|
echo -e "${CYAN}Workflows for $REPO${NC}"
|
||||||
|
echo ""
|
||||||
|
curl -s -H "$AUTH" "$API/repos/$REPO/actions/workflows" 2>/dev/null \
|
||||||
|
| python3 -c "
|
||||||
|
import json, sys
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for w in data.get('workflows', []):
|
||||||
|
state = '✓' if w['state'] == 'active' else '✗'
|
||||||
|
print(f\" {state} {w['id']:30s} {w['name']}\")
|
||||||
|
" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local limit="${1:-10}"
|
||||||
|
echo -e "${CYAN}Recent runs for $REPO${NC}"
|
||||||
|
echo ""
|
||||||
|
curl -s -H "$AUTH" "$API/repos/$REPO/actions/runs?limit=$limit" 2>/dev/null \
|
||||||
|
| python3 -c "
|
||||||
|
import json, sys
|
||||||
|
limit = $limit
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for r in data.get('workflow_runs', [])[:limit]:
|
||||||
|
status = r.get('status', '?')
|
||||||
|
num = r.get('run_number', 0)
|
||||||
|
title = r.get('display_title', '')[:60]
|
||||||
|
wf = r.get('path', '')
|
||||||
|
wf = wf.split('@')[0] if '@' in wf else wf
|
||||||
|
icon = {'success':'\u2713','completed':'\u2713','failure':'\u2717','cancelled':'\u2717','in_progress':'\u27f3','running':'\u27f3','queued':'\u25cc','waiting':'\u25cc'}.get(status, '?')
|
||||||
|
color = {'success':'\033[0;32m','completed':'\033[0;32m','failure':'\033[0;31m','cancelled':'\033[0;31m','in_progress':'\033[0;33m','running':'\033[0;33m'}.get(status, '\033[0;37m')
|
||||||
|
print(f\"{color}{icon}\033[0m #{num:<4} {status:<12} {wf:<20} {title}\")
|
||||||
|
" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_view() {
|
||||||
|
local run_number="${1:-}"
|
||||||
|
if [ -z "$run_number" ]; then
|
||||||
|
echo "Usage: gitea-runs view <run_number>"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find run by run_number (API uses internal id, html uses run_number)
|
||||||
|
local run_data
|
||||||
|
run_data=$(curl -s -H "$AUTH" "$API/repos/$REPO/actions/runs?limit=50" 2>/dev/null \
|
||||||
|
| python3 -c "
|
||||||
|
import json, sys
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for r in data.get('workflow_runs', []):
|
||||||
|
if r['run_number'] == $run_number:
|
||||||
|
print(json.dumps(r))
|
||||||
|
break
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$run_data" ]; then
|
||||||
|
echo -e "${RED}✗${NC} Run #$run_number not found"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local run_id
|
||||||
|
run_id=$(echo "$run_data" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
|
||||||
|
|
||||||
|
# Print run info
|
||||||
|
echo "$run_data" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
r = json.load(sys.stdin)
|
||||||
|
status = r.get('status', '?')
|
||||||
|
icon = {'success':'\u2713','failure':'\u2717','in_progress':'\u27f3','queued':'\u25cc'}.get(status, '?')
|
||||||
|
color = {'success':'\033[0;32m','failure':'\033[0;31m','in_progress':'\033[0;33m'}.get(status, '\033[0;37m')
|
||||||
|
print(f\"{color}{icon} Run #{r.get('run_number',0)} \u2014 {status}\033[0m\")
|
||||||
|
print(f\" Title: {r.get('display_title','')}\")
|
||||||
|
print(f\" Event: {r.get('event','')}\")
|
||||||
|
print(f\" Branch: {r.get('head_branch','')}\")
|
||||||
|
print(f\" Commit: {r.get('head_sha','')[:8]}\")
|
||||||
|
print(f\" Actor: {r.get('actor',{}).get('login','')}\")
|
||||||
|
wf = r.get('path', '')
|
||||||
|
wf = wf.split('@')[0] if '@' in wf else wf
|
||||||
|
print(f\" Workflow: {wf}\")
|
||||||
|
" 2>/dev/null
|
||||||
|
|
||||||
|
# Print jobs
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Jobs:${NC}"
|
||||||
|
curl -s -H "$AUTH" "$API/repos/$REPO/actions/runs/$run_id/jobs" 2>/dev/null \
|
||||||
|
| python3 -c "
|
||||||
|
import json, sys
|
||||||
|
from datetime import datetime
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for j in data.get('jobs', []):
|
||||||
|
status = j.get('status', '?')
|
||||||
|
icon = {'success':'\u2713','failure':'\u2717','in_progress':'\u27f3','queued':'\u25cc','waiting':'\u25cc'}.get(status, '?')
|
||||||
|
color = {'success':'\033[0;32m','failure':'\033[0;31m','in_progress':'\033[0;33m'}.get(status, '\033[0;37m')
|
||||||
|
runner = j.get('runner_name', '-')
|
||||||
|
started = j.get('started_at', '')[:19].replace('T', ' ')
|
||||||
|
completed = j.get('completed_at', '')[:19].replace('T', ' ')
|
||||||
|
duration = ''
|
||||||
|
if completed and not completed.startswith('1970'):
|
||||||
|
try:
|
||||||
|
d = datetime.fromisoformat(completed) - datetime.fromisoformat(started)
|
||||||
|
duration = f' ({int(d.total_seconds())}s)'
|
||||||
|
except: pass
|
||||||
|
print(f\" {color}{icon}\033[0m {j.get('name',''):<30} {status:<12} runner: {runner}{duration}\")
|
||||||
|
" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_open() {
|
||||||
|
local run_id="${1:-}"
|
||||||
|
local url="$GITEA_URL/$REPO/actions"
|
||||||
|
if [ -n "$run_id" ]; then
|
||||||
|
url="$url/runs/$run_id"
|
||||||
|
fi
|
||||||
|
echo "Opening: $url"
|
||||||
|
open "$url" 2>/dev/null || xdg-open "$url" 2>/dev/null || echo "$url"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_help() {
|
||||||
|
echo "gitea-runs — Gitea Actions CLI helper"
|
||||||
|
echo ""
|
||||||
|
echo "Usage:"
|
||||||
|
echo " gitea-runs List recent runs"
|
||||||
|
echo " gitea-runs list [limit] List recent runs (default 10)"
|
||||||
|
echo " gitea-runs view <run_number> View run details & jobs"
|
||||||
|
echo " gitea-runs open [run_number] Open run in browser"
|
||||||
|
echo " gitea-runs workflows List workflows"
|
||||||
|
echo " gitea-runs dispatch <wf> [ref] Trigger a workflow dispatch"
|
||||||
|
echo " gitea-runs help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Repo: $REPO"
|
||||||
|
echo "Gitea: $GITEA_URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
case "${1:-}" in
|
||||||
|
list|ls) shift; cmd_list "$@" ;;
|
||||||
|
view|v) shift; cmd_view "$@" ;;
|
||||||
|
dispatch) shift; cmd_dispatch "$@" ;;
|
||||||
|
workflows|wf) cmd_workflows ;;
|
||||||
|
open|o) shift; cmd_open "$@" ;;
|
||||||
|
help|--help|-h) cmd_help ;;
|
||||||
|
"") cmd_list ;;
|
||||||
|
*) cmd_view "$1" ;;
|
||||||
|
esac
|
||||||
281
plugins/gitea-plugin/skills/SKILL.md
Normal file
281
plugins/gitea-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
---
|
||||||
|
name: gitea
|
||||||
|
description: Gitea 代码托管与 CI/CD 管理。用于 Gitea Actions workflow 管理、Runner 管理、PR 操作、仓库配置。当用户提到 Gitea、Actions、Runner、CI/CD workflow、PR 检查相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gitea Skill
|
||||||
|
|
||||||
|
Gitea 代码托管平台管理,覆盖 Actions CI/CD、Runner、PR、仓库配置。
|
||||||
|
|
||||||
|
## 服务器信息
|
||||||
|
|
||||||
|
| 服务 | 地址 | SSH |
|
||||||
|
|------|------|-----|
|
||||||
|
| Gitea Web | https://gitea.pipexerp.com | — |
|
||||||
|
| Gitea SSH | gitea.pipexerp.com:10022 | `ssh -i ~/.ssh/id_ed25519 git@gitea.pipexerp.com -p 10022` |
|
||||||
|
| Gitea 服务器 | 123.56.89.187 | `ssh -i ~/.ssh/tools.pem root@123.56.89.187` |
|
||||||
|
| Runner 服务器 | 101.200.136.200 (Jenkins 服务器) | `ssh -i ~/.ssh/tools.pem root@101.200.136.200` |
|
||||||
|
|
||||||
|
## API 访问
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Gitea API Token (仓库级)
|
||||||
|
GITEA_TOKEN="483a2b65219625ee382eb6d023cda39238c32e24"
|
||||||
|
|
||||||
|
# 通用请求格式
|
||||||
|
curl -s "https://gitea.pipexerp.com/api/v1/repos/pipexerp/<repo>/..." \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用 API
|
||||||
|
|
||||||
|
| 操作 | 方法 | 端点 |
|
||||||
|
|------|------|------|
|
||||||
|
| 创建 PR | POST | `/repos/{owner}/{repo}/pulls` |
|
||||||
|
| 更新 PR | PATCH | `/repos/{owner}/{repo}/pulls/{id}` |
|
||||||
|
| 列出 Runs | GET | `/repos/{owner}/{repo}/actions/runs` |
|
||||||
|
| Run 详情 | GET | `/repos/{owner}/{repo}/actions/runs/{id}` |
|
||||||
|
| Job 详情 | GET | `/repos/{owner}/{repo}/actions/runs/{id}/jobs` |
|
||||||
|
| 手动触发 Workflow | POST | `/repos/{owner}/{repo}/actions/workflows/{file}/dispatches` body: `{"ref":"main"}` |
|
||||||
|
| 获取 Runner Token | POST | `/repos/{owner}/{repo}/actions/runners/registration-token` |
|
||||||
|
| 添加 Secret | PUT | `/repos/{owner}/{repo}/actions/secrets/{name}` body: `{"data":"value"}` |
|
||||||
|
| 删除 Run(仅已完成)| DELETE | `/repos/{owner}/{repo}/actions/runs/{id}` |
|
||||||
|
|
||||||
|
**注意**: Gitea 1.25 **不支持**通过 API cancel 正在排队/运行的 run。
|
||||||
|
|
||||||
|
## 仓库
|
||||||
|
|
||||||
|
| 仓库 | 地址 | 主分支 |
|
||||||
|
|------|------|--------|
|
||||||
|
| coolbuy-paas | pipexerp/coolbuy-paas | main |
|
||||||
|
| dotfiles | huangjun/dotfiles | main |
|
||||||
|
| claude-marketplace | huangjun/claude-marketplace | main |
|
||||||
|
|
||||||
|
## Actions Runners
|
||||||
|
|
||||||
|
### 主 Runner (lint/test/e2e)
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 名称 | jenkins-runner |
|
||||||
|
| 配置 | `/opt/act_runner/config.yaml` |
|
||||||
|
| Capacity | 3 |
|
||||||
|
| Labels | `ubuntu-latest`, `ubuntu-22.04`, `ubuntu-20.04` |
|
||||||
|
| 进程 | `/usr/local/bin/act_runner daemon --config /opt/act_runner/config.yaml` |
|
||||||
|
|
||||||
|
### Deploy Runner (staging 部署专用)
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 名称 | deploy-runner |
|
||||||
|
| 配置 | `/opt/act_runner_deploy/config.yaml` |
|
||||||
|
| Capacity | 1 |
|
||||||
|
| Labels | `deploy:host` |
|
||||||
|
| PID | `/opt/act_runner_deploy/runner.pid` |
|
||||||
|
| 日志 | `/opt/act_runner_deploy/runner.log` |
|
||||||
|
| 启动 | `cd /opt/act_runner_deploy && nohup act_runner daemon --config config.yaml > runner.log 2>&1 &` |
|
||||||
|
|
||||||
|
### 注册新 Runner
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 获取 registration token
|
||||||
|
curl -s -X POST "https://gitea.pipexerp.com/api/v1/repos/pipexerp/coolbuy-paas/actions/runners/registration-token" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN"
|
||||||
|
|
||||||
|
# 2. SSH 到 runner 服务器
|
||||||
|
ssh -i ~/.ssh/tools.pem root@101.200.136.200
|
||||||
|
|
||||||
|
# 3. 创建目录和配置
|
||||||
|
mkdir -p /opt/act_runner_<name>
|
||||||
|
cat > /opt/act_runner_<name>/config.yaml << 'EOF'
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
runner:
|
||||||
|
file: .runner
|
||||||
|
capacity: 1
|
||||||
|
timeout: 30m
|
||||||
|
labels:
|
||||||
|
- "<label>:host" # host 模式用系统 shell
|
||||||
|
# 或 "<label>:docker://image" # docker 模式
|
||||||
|
cache:
|
||||||
|
enabled: false
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 4. 注册
|
||||||
|
cd /opt/act_runner_<name>
|
||||||
|
act_runner register --instance https://gitea.pipexerp.com \
|
||||||
|
--token <TOKEN> --name <NAME> --labels '<LABEL>:host' \
|
||||||
|
--config config.yaml --no-interactive
|
||||||
|
|
||||||
|
# 5. 启动
|
||||||
|
nohup act_runner daemon --config config.yaml > runner.log 2>&1 &
|
||||||
|
echo $! > runner.pid
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runner 运维
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 runner 状态
|
||||||
|
ssh -i ~/.ssh/tools.pem root@101.200.136.200 "ps aux | grep act_runner | grep -v grep"
|
||||||
|
|
||||||
|
# 查看 deploy runner 日志
|
||||||
|
ssh -i ~/.ssh/tools.pem root@101.200.136.200 "tail -20 /opt/act_runner_deploy/runner.log"
|
||||||
|
|
||||||
|
# 重启 deploy runner
|
||||||
|
ssh -i ~/.ssh/tools.pem root@101.200.136.200 "
|
||||||
|
kill \$(cat /opt/act_runner_deploy/runner.pid) 2>/dev/null
|
||||||
|
cd /opt/act_runner_deploy
|
||||||
|
nohup act_runner daemon --config config.yaml > runner.log 2>&1 &
|
||||||
|
echo \$! > runner.pid
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflows (coolbuy-paas)
|
||||||
|
|
||||||
|
| Workflow | 触发 | Runner | paths-ignore | 用途 |
|
||||||
|
|----------|------|--------|-------------|------|
|
||||||
|
| 🚀 deploy-staging.yml | push → main | `deploy` | md, docs, .gitea, scripts, *_test.go | 触发 Jenkins 部署到 staging |
|
||||||
|
| 🔍 lint.yml | PR → main | `ubuntu-latest` | md, docs, .gitea, scripts | Go lint + ESLint auto-fix |
|
||||||
|
| 🧪 unit-test.yml | PR → main | `ubuntu-latest` | md, docs, .gitea, scripts | 4 个 Go 服务单元测试 |
|
||||||
|
| 🎭 e2e-tests.yml | schedule 12h | `ubuntu-latest` | — | Playwright E2E(仅定时) |
|
||||||
|
| 📋 notify-aiproj.yml | PR merged | `ubuntu-latest` | — | 同步需求状态到 ai-proj |
|
||||||
|
| 📦 build.yaml | 手动 | `ubuntu-latest` | — | Docker 构建推 Hub |
|
||||||
|
|
||||||
|
### Workflow 编写规范
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 1. 名称加 emoji 前缀
|
||||||
|
name: "🚀 Deploy Staging"
|
||||||
|
|
||||||
|
# 2. 非代码变更加 paths-ignore
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- '.gitea/**'
|
||||||
|
- 'scripts/**'
|
||||||
|
|
||||||
|
# 3. 加 concurrency 防重复
|
||||||
|
concurrency:
|
||||||
|
group: deploy-staging
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# 4. 仅定时的 workflow 加 event 守卫
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
if: github.event_name == 'schedule'
|
||||||
|
|
||||||
|
# 5. auto-fix 提交加 [skip ci]
|
||||||
|
git commit -m "style: auto-fix [skip ci]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkout 模式(容器内)
|
||||||
|
|
||||||
|
Gitea Actions 不支持 `actions/checkout`,用原生 git:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$(pwd)"
|
||||||
|
git init
|
||||||
|
git remote add origin "https://oauth2:${TOKEN}@gitea.pipexerp.com/${{ github.repository }}.git"
|
||||||
|
git fetch origin "${{ github.event.pull_request.head.ref }}"
|
||||||
|
git checkout -b pr-branch "origin/${{ github.event.pull_request.head.ref }}"
|
||||||
|
git config user.name "CI Bot"
|
||||||
|
git config user.email "ci@pipexerp.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets 管理
|
||||||
|
|
||||||
|
### 当前 Secrets (coolbuy-paas 仓库级)
|
||||||
|
|
||||||
|
| Secret | 用途 |
|
||||||
|
|--------|------|
|
||||||
|
| `JENKINS_USER` | Jenkins API 用户名 |
|
||||||
|
| `JENKINS_TOKEN` | Jenkins API Token |
|
||||||
|
| `DOCKER_HUB_TOKEN` | Docker Hub 推送 |
|
||||||
|
| `AI_PROJ_TOKEN` | ai-proj API 认证 |
|
||||||
|
|
||||||
|
### 添加/更新 Secret
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT \
|
||||||
|
"https://gitea.pipexerp.com/api/v1/repos/pipexerp/coolbuy-paas/actions/secrets/<NAME>" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"data": "<VALUE>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD 完整流程
|
||||||
|
|
||||||
|
```
|
||||||
|
PR → main
|
||||||
|
├── 🧪 unit-test.yml (Go 服务测试)
|
||||||
|
├── 🔍 lint.yml (auto-fix 格式)
|
||||||
|
└── merge 后:
|
||||||
|
├── 📋 notify-aiproj.yml (需求状态 → testing)
|
||||||
|
└── 🚀 deploy-staging.yml (Jenkins → staging)
|
||||||
|
├── ≥5 commits/2h → 立即部署
|
||||||
|
└── <5 commits → 等3分钟 debounce
|
||||||
|
|
||||||
|
定时:
|
||||||
|
└── 🎭 e2e-tests.yml (每12h Playwright)
|
||||||
|
|
||||||
|
手动:
|
||||||
|
└── 📦 build.yaml (Docker 构建推 Hub)
|
||||||
|
|
||||||
|
生产部署:
|
||||||
|
└── ./scripts/build-and-push.sh prod --deploy (触发 Jenkins)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地 CLI 工具
|
||||||
|
|
||||||
|
### tea CLI (Gitea 官方命令行)
|
||||||
|
|
||||||
|
tea 是 Gitea 官方 CLI 客户端,已配置好认证信息。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 配置文件位置
|
||||||
|
~/Library/Application Support/tea/config.yml
|
||||||
|
# 或 ~/.config/tea/config.yml
|
||||||
|
|
||||||
|
# gitea-runs 脚本从 tea config 读取 url 和 token
|
||||||
|
```
|
||||||
|
|
||||||
|
### gitea-runs (Actions 快捷命令)
|
||||||
|
|
||||||
|
位置: `~/.local/bin/gitea-runs`
|
||||||
|
|
||||||
|
自动从 git remote 检测仓库,从 tea CLI 配置读取认证信息。
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `gitea-runs` | 列出最近 10 条 run |
|
||||||
|
| `gitea-runs list [N]` | 列出最近 N 条 run |
|
||||||
|
| `gitea-runs view <run_number>` | 查看 run 详情和 jobs |
|
||||||
|
| `gitea-runs open [run_number]` | 在浏览器打开 run 页面 |
|
||||||
|
| `gitea-runs workflows` | 列出所有 workflow |
|
||||||
|
| `gitea-runs dispatch <wf> [ref]` | 手动触发 workflow |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 示例
|
||||||
|
gitea-runs # 查看最近 runs
|
||||||
|
gitea-runs view 303 # 查看 run #303 详情
|
||||||
|
gitea-runs dispatch deploy-staging.yml main # 手动触发部署
|
||||||
|
gitea-runs open # 打开 Actions 页面
|
||||||
|
```
|
||||||
|
|
||||||
|
**优先使用 `gitea-runs` 而非 curl API**,更简洁且自动处理认证。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
| 问题 | 原因 | 解决 |
|
||||||
|
|------|------|------|
|
||||||
|
| Run 一直 queued | Runner 被占满 | 等其他 job 完成,或加 runner |
|
||||||
|
| deploy 被 test 阻塞 | 共用 runner | 用 `runs-on: deploy` 专属 runner |
|
||||||
|
| Workflow 被误触发 | push 新 workflow 文件到 main | 加 `if: github.event_name == 'schedule'` 守卫 |
|
||||||
|
| auto-fix 无限循环 | 提交触发新 run | 提交信息加 `[skip ci]` |
|
||||||
|
| API 无法 cancel run | Gitea 1.25 限制 | 网页手动取消,或等完成后 DELETE |
|
||||||
|
| `date -d` 报错 | 容器 date 不兼容 | 用 host 模式 runner,或兼容写法 |
|
||||||
8
plugins/openclaw-ops-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/openclaw-ops-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-ops-plugin",
|
||||||
|
"description": "Plugin for openclaw-ops",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
797
plugins/openclaw-ops-plugin/skills/SKILL.md
Normal file
797
plugins/openclaw-ops-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
# OpenClaw 运维技能
|
||||||
|
|
||||||
|
OpenClaw 容器化部署、运维监控、故障排查完整指南。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [服务器部署](#服务器部署)
|
||||||
|
- [容器管理](#容器管理)
|
||||||
|
- [性能优化](#性能优化)
|
||||||
|
- [故障排查](#故障排查)
|
||||||
|
- [最佳实践](#最佳实践)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 服务器部署
|
||||||
|
|
||||||
|
### 懒猫算力仓 (lazycat)
|
||||||
|
|
||||||
|
**服务器信息**:
|
||||||
|
- 主机名:haiqing.heiyu.space
|
||||||
|
- SSH 别名:lazycat, lanmao
|
||||||
|
- 用途:OpenClaw 算力服务
|
||||||
|
- 系统:Debian-based Linux
|
||||||
|
- 容器平台:lzc-docker
|
||||||
|
|
||||||
|
**OpenClaw 容器信息**:
|
||||||
|
- 容器 ID:5f3bf33e090b
|
||||||
|
- 镜像:registry.lazycat.cloud/openclaw:1.1.5
|
||||||
|
- OpenClaw 版本:2026.2.9
|
||||||
|
- 容器名:iamxiaoelzcappopenclaw-openclaw-1
|
||||||
|
|
||||||
|
**访问方式**:
|
||||||
|
```bash
|
||||||
|
# SSH 连接
|
||||||
|
ssh lazycat
|
||||||
|
|
||||||
|
# 进入容器
|
||||||
|
ssh lazycat "lzc-docker exec -it 5f3bf33e090b bash"
|
||||||
|
|
||||||
|
# 启动 OpenClaw TUI
|
||||||
|
openclaw-tui # 使用本地快捷脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 容器管理
|
||||||
|
|
||||||
|
### 快捷访问脚本
|
||||||
|
|
||||||
|
**~/bin/openclaw-tui**:
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# OpenClaw TUI 快捷访问脚本(自动启动 Gateway)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🦞 连接到龙虾服务器 (懒猫)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查并启动 Gateway
|
||||||
|
echo "检查 OpenClaw Gateway 状态..."
|
||||||
|
GATEWAY_STATUS=$(ssh lazycat "lzc-docker exec 5f3bf33e090b openclaw gateway status" 2>/dev/null | grep "RPC probe" || echo "failed")
|
||||||
|
|
||||||
|
if echo "$GATEWAY_STATUS" | grep -q "ok"; then
|
||||||
|
echo "✅ Gateway 已运行"
|
||||||
|
else
|
||||||
|
echo "🔧 启动 Gateway..."
|
||||||
|
ssh lazycat "lzc-docker exec -d 5f3bf33e090b bash -c 'nohup openclaw gateway run > /tmp/gateway.log 2>&1 &'" 2>/dev/null
|
||||||
|
sleep 2
|
||||||
|
echo "✅ Gateway 已启动"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "启动 OpenClaw TUI..."
|
||||||
|
|
||||||
|
# SSH 到懒猫服务器,然后进入 Docker 容器并启动 OpenClaw TUI
|
||||||
|
ssh -t lazycat "lzc-docker exec -it 5f3bf33e090b bash -c 'openclaw tui'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 容器操作命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看容器状态
|
||||||
|
ssh lazycat "lzc-docker ps | grep openclaw"
|
||||||
|
|
||||||
|
# 查看容器日志
|
||||||
|
ssh lazycat "lzc-docker logs -f 5f3bf33e090b --tail 100"
|
||||||
|
|
||||||
|
# 重启容器
|
||||||
|
ssh lazycat "lzc-docker restart 5f3bf33e090b"
|
||||||
|
|
||||||
|
# 查看容器资源使用
|
||||||
|
ssh lazycat "lzc-docker stats --no-stream 5f3bf33e090b"
|
||||||
|
|
||||||
|
# 进入容器 shell
|
||||||
|
ssh lazycat "lzc-docker exec -it 5f3bf33e090b bash"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gateway 管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 Gateway 状态
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b openclaw gateway status"
|
||||||
|
|
||||||
|
# 启动 Gateway(前台)
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b openclaw gateway run"
|
||||||
|
|
||||||
|
# 启动 Gateway(后台)
|
||||||
|
ssh lazycat "lzc-docker exec -d 5f3bf33e090b bash -c 'nohup openclaw gateway run > /tmp/gateway.log 2>&1 &'"
|
||||||
|
|
||||||
|
# 停止 Gateway
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b pkill -f 'openclaw-gateway'"
|
||||||
|
|
||||||
|
# 查看 Gateway 日志
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b tail -f /tmp/openclaw/openclaw-$(date +%Y-%m-%d).log"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 资源配置
|
||||||
|
|
||||||
|
**当前配置**(接近系统上限,确保充足性能):
|
||||||
|
- 内存限制:30GB(系统 97%)
|
||||||
|
- 内存+交换:32GB
|
||||||
|
- CPU 限制:8.0 核心(系统 100%)
|
||||||
|
- 进程限制:10,000 个
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看资源限制配置
|
||||||
|
ssh lazycat "lzc-docker inspect 5f3bf33e090b --format='
|
||||||
|
内存限制: {{.HostConfig.Memory}} bytes
|
||||||
|
内存+交换: {{.HostConfig.MemorySwap}} bytes
|
||||||
|
CPU配额: {{.HostConfig.CpuQuota}}
|
||||||
|
CPU周期: {{.HostConfig.CpuPeriod}}
|
||||||
|
PID限制: {{.HostConfig.PidsLimit}}
|
||||||
|
'"
|
||||||
|
|
||||||
|
# 查看实时资源使用
|
||||||
|
ssh lazycat "lzc-docker stats --no-stream 5f3bf33e090b"
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置说明**:
|
||||||
|
- 懒猫算力仓的主要职责是提供 OpenClaw 服务
|
||||||
|
- 资源限制设置为接近系统上限,确保有充足资源运行
|
||||||
|
- 同时提供基本的失控保护机制
|
||||||
|
|
||||||
|
### 自动化优化措施
|
||||||
|
|
||||||
|
#### 1. 定期自动重启(每周日 03:00)
|
||||||
|
|
||||||
|
**目的**:清理累积的僵尸进程,释放资源
|
||||||
|
|
||||||
|
**查看状态**:
|
||||||
|
```bash
|
||||||
|
# 查看定时任务状态
|
||||||
|
ssh lazycat "systemctl status openclaw-restart.timer"
|
||||||
|
|
||||||
|
# 查看重启日志
|
||||||
|
ssh lazycat "tail -50 /var/log/openclaw-restart.log"
|
||||||
|
|
||||||
|
# 手动执行重启
|
||||||
|
ssh lazycat "/root/restart-openclaw.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置文件**:
|
||||||
|
- Service: `/etc/systemd/system/openclaw-restart.service`
|
||||||
|
- Timer: `/etc/systemd/system/openclaw-restart.timer`
|
||||||
|
- 脚本: `/root/restart-openclaw.sh`
|
||||||
|
|
||||||
|
**重启脚本** (`/root/restart-openclaw.sh`):
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# OpenClaw 容器定期重启脚本
|
||||||
|
# 每周日凌晨3点执行
|
||||||
|
|
||||||
|
LOG_FILE='/var/log/openclaw-restart.log'
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始重启 OpenClaw 容器" >> $LOG_FILE
|
||||||
|
|
||||||
|
# 重启容器
|
||||||
|
/lzcsys/bin/lzc-docker restart 5f3bf33e090b >> $LOG_FILE 2>&1
|
||||||
|
|
||||||
|
# 等待容器启动
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# 检查健康状态
|
||||||
|
STATUS=$(/lzcsys/bin/lzc-docker inspect -f '{{.State.Health.Status}}' 5f3bf33e090b 2>/dev/null || echo 'unknown')
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 重启完成,健康状态: $STATUS" >> $LOG_FILE
|
||||||
|
|
||||||
|
# 检查僵尸进程
|
||||||
|
ZOMBIE_COUNT=$(/lzcsys/bin/lzc-docker exec 5f3bf33e090b ps aux | grep 'Z' | wc -l)
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 当前僵尸进程数: $ZOMBIE_COUNT" >> $LOG_FILE
|
||||||
|
echo "----------------------------------------" >> $LOG_FILE
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 僵尸进程自动监控(每小时检查)
|
||||||
|
|
||||||
|
**目的**:监控僵尸进程数量,超过阈值自动重启容器
|
||||||
|
|
||||||
|
**查看状态**:
|
||||||
|
```bash
|
||||||
|
# 查看监控状态
|
||||||
|
ssh lazycat "systemctl status openclaw-zombie-monitor.timer"
|
||||||
|
|
||||||
|
# 查看监控日志
|
||||||
|
ssh lazycat "tail -50 /var/log/openclaw-zombie-monitor.log"
|
||||||
|
|
||||||
|
# 手动检查僵尸进程
|
||||||
|
ssh lazycat "/root/monitor-openclaw-zombies.sh"
|
||||||
|
|
||||||
|
# 直接查看僵尸进程数
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep 'Z' | wc -l"
|
||||||
|
```
|
||||||
|
|
||||||
|
**监控参数**:
|
||||||
|
- 检查频率:每小时
|
||||||
|
- 触发阈值:50 个僵尸进程
|
||||||
|
- 自动操作:重启容器
|
||||||
|
|
||||||
|
**监控脚本** (`/root/monitor-openclaw-zombies.sh`):
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# OpenClaw 僵尸进程监控脚本
|
||||||
|
# 当僵尸进程超过50个时自动重启容器
|
||||||
|
|
||||||
|
ZOMBIE_THRESHOLD=50
|
||||||
|
CONTAINER_ID='5f3bf33e090b'
|
||||||
|
LOG_FILE='/var/log/openclaw-zombie-monitor.log'
|
||||||
|
|
||||||
|
# 检查僵尸进程数量
|
||||||
|
ZOMBIE_COUNT=$(/lzcsys/bin/lzc-docker exec $CONTAINER_ID ps aux 2>/dev/null | grep -c 'Z' || echo '0')
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 僵尸进程数: $ZOMBIE_COUNT" >> $LOG_FILE
|
||||||
|
|
||||||
|
if [ $ZOMBIE_COUNT -gt $ZOMBIE_THRESHOLD ]; then
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ⚠️ 僵尸进程超过阈值($ZOMBIE_THRESHOLD),执行自动重启" >> $LOG_FILE
|
||||||
|
|
||||||
|
# 重启容器
|
||||||
|
/lzcsys/bin/lzc-docker restart $CONTAINER_ID >> $LOG_FILE 2>&1
|
||||||
|
|
||||||
|
# 等待容器启动
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# 再次检查
|
||||||
|
NEW_ZOMBIE_COUNT=$(/lzcsys/bin/lzc-docker exec $CONTAINER_ID ps aux 2>/dev/null | grep -c 'Z' || echo '0')
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 重启后僵尸进程数: $NEW_ZOMBIE_COUNT" >> $LOG_FILE
|
||||||
|
echo "----------------------------------------" >> $LOG_FILE
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 全面健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一键健康检查脚本
|
||||||
|
ssh lazycat "
|
||||||
|
echo '=== 系统负载 ===' && uptime &&
|
||||||
|
echo '' && echo '=== 僵尸进程 ===' &&
|
||||||
|
lzc-docker exec 5f3bf33e090b ps aux | grep 'Z' | wc -l &&
|
||||||
|
echo '' && echo '=== 容器资源 ===' &&
|
||||||
|
lzc-docker stats --no-stream 5f3bf33e090b &&
|
||||||
|
echo '' && echo '=== Gateway 状态 ===' &&
|
||||||
|
lzc-docker exec 5f3bf33e090b openclaw gateway status | grep 'RPC probe' &&
|
||||||
|
echo '' && echo '=== 容器健康 ===' &&
|
||||||
|
lzc-docker inspect 5f3bf33e090b --format='Status: {{.State.Status}}, Health: {{.State.Health.Status}}'
|
||||||
|
"
|
||||||
|
|
||||||
|
# 查看所有定时任务
|
||||||
|
ssh lazycat "systemctl list-timers | grep openclaw"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### Tower 反复崩溃(已修复 2026-02-16)
|
||||||
|
|
||||||
|
**现象**:
|
||||||
|
- Tower 日志显示反复崩溃:`[tower] OpenClaw crashed: exit status 1`
|
||||||
|
- Gateway 启动失败:`gateway already running (pid xxx); lock timeout`
|
||||||
|
- 僵尸 Gateway 进程堆积,无法回收
|
||||||
|
- 日志中出现多个僵尸进程:`[openclaw-gatewa] <defunct>`
|
||||||
|
|
||||||
|
**典型错误日志**:
|
||||||
|
```
|
||||||
|
[22:19:39] [tower] OpenClaw crashed: exit status 1
|
||||||
|
[22:24:52] [tower] OpenClaw crashed: signal: killed
|
||||||
|
[22:27:33] Gateway failed to start: gateway already running (pid 2005)
|
||||||
|
[22:27:33] If the gateway is supervised, stop it with: openclaw gateway stop
|
||||||
|
```
|
||||||
|
|
||||||
|
**根本原因**:
|
||||||
|
- Tower 作为容器 PID 1 进程,不是专业的 init 进程
|
||||||
|
- 缺少子进程回收(reaping)机制,导致僵尸进程未被清理
|
||||||
|
- 僵尸进程占用锁文件和端口(18789),阻塞新 Gateway 启动
|
||||||
|
- 容器 PID 1 是 `/usr/local/bin/tower`,没有僵尸进程回收能力
|
||||||
|
|
||||||
|
**诊断命令**:
|
||||||
|
```bash
|
||||||
|
# 查看 PID 1 进程
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps -p 1 -o pid,ppid,cmd"
|
||||||
|
|
||||||
|
# 查看僵尸进程详情
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep 'defunct'"
|
||||||
|
|
||||||
|
# 检查端口占用
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b netstat -tlnp | grep 18789"
|
||||||
|
|
||||||
|
# 查看进程树
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps auxf | head -30"
|
||||||
|
```
|
||||||
|
|
||||||
|
**永久解决方案(已实施)**:
|
||||||
|
|
||||||
|
使用 **tini** 作为容器 PID 1,自动回收僵尸进程。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 在容器中安装 tini(专业 init 进程)
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b bash -c 'apt-get update -qq && apt-get install -y tini'"
|
||||||
|
|
||||||
|
# 2. 修改 entrypoint 使用 tini 包装 tower
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b sed -i 's|exec /usr/local/bin/tower|exec /usr/bin/tini -- /usr/local/bin/tower|g' /usr/local/bin/clawdbot-entrypoint.sh"
|
||||||
|
|
||||||
|
# 3. 验证修改
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b grep 'exec.*tower' /usr/local/bin/clawdbot-entrypoint.sh"
|
||||||
|
# 应该看到: exec /usr/bin/tini -- /usr/local/bin/tower ...
|
||||||
|
|
||||||
|
# 4. 重启容器使修改生效
|
||||||
|
ssh lazycat "lzc-docker restart 5f3bf33e090b"
|
||||||
|
|
||||||
|
# 5. 验证 tini 已成为 PID 1
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps -p 1 -o pid,ppid,cmd"
|
||||||
|
# 输出应显示: PID 1 -> /usr/bin/tini -- /usr/local/bin/tower ...
|
||||||
|
|
||||||
|
# 6. 检查进程树
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps auxf | head -15"
|
||||||
|
```
|
||||||
|
|
||||||
|
**修复后的进程架构**:
|
||||||
|
```
|
||||||
|
PID 1: /usr/bin/tini (专业 init 进程,自动回收僵尸进程)
|
||||||
|
└─ PID 58: tower
|
||||||
|
└─ PID 64: openclaw
|
||||||
|
└─ PID 72: openclaw-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
**修复效果**:
|
||||||
|
- ✅ Tini 作为 PID 1,自动回收所有僵尸进程
|
||||||
|
- ✅ 僵尸进程数量从 5+ 个降至 1-2 个(健康水平)
|
||||||
|
- ✅ Tower 稳定运行,不再反复崩溃
|
||||||
|
- ✅ Gateway 启动正常,无锁文件冲突
|
||||||
|
- ✅ RPC probe 持续显示 ok
|
||||||
|
|
||||||
|
**注意事项**:
|
||||||
|
- ⚠️ 当前修改在运行容器内,**容器重建后需重新应用**
|
||||||
|
- 💡 建议向镜像维护者(懒猫云)提交 PR,在 Dockerfile 中添加 tini
|
||||||
|
- 📌 每次从镜像重新创建容器时,需要重新执行上述步骤 1-4
|
||||||
|
|
||||||
|
**镜像级永久修复**(建议提交给懒猫云):
|
||||||
|
|
||||||
|
在 OpenClaw 镜像的 Dockerfile 中添加:
|
||||||
|
```dockerfile
|
||||||
|
# 安装 tini
|
||||||
|
RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 或使用更轻量的安装方式
|
||||||
|
ADD https://github.com/krallin/tini/releases/download/v0.19.0/tini /usr/bin/tini
|
||||||
|
RUN chmod +x /usr/bin/tini
|
||||||
|
|
||||||
|
# 在 entrypoint 脚本中使用 tini 包装(已在当前镜像的 entrypoint 中修改)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 僵尸进程过多
|
||||||
|
|
||||||
|
**现象**:
|
||||||
|
- 僵尸进程数超过 50 个
|
||||||
|
- Gateway 响应变慢
|
||||||
|
- 容器内存占用升高
|
||||||
|
|
||||||
|
**诊断**:
|
||||||
|
```bash
|
||||||
|
# 查看僵尸进程详情
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep 'Z'"
|
||||||
|
|
||||||
|
# 统计僵尸进程数量
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep -c 'Z'"
|
||||||
|
|
||||||
|
# 查看僵尸进程父进程
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps -eo pid,ppid,stat,comm | grep 'Z'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```bash
|
||||||
|
# 方案 1:重启容器(推荐)
|
||||||
|
ssh lazycat "lzc-docker restart 5f3bf33e090b"
|
||||||
|
|
||||||
|
# 方案 2:手动清理 Gateway 进程
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b pkill -9 -f 'openclaw-gateway'"
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b openclaw gateway run &"
|
||||||
|
|
||||||
|
# 验证清理效果
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep -c 'Z'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gateway 无响应
|
||||||
|
|
||||||
|
**现象**:
|
||||||
|
- `RPC probe: failed` 或超时
|
||||||
|
- TUI 连接失败:`gateway not connected`
|
||||||
|
- Dashboard 无法访问
|
||||||
|
|
||||||
|
**诊断**:
|
||||||
|
```bash
|
||||||
|
# 检查 Gateway 进程
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep gateway"
|
||||||
|
|
||||||
|
# 检查端口监听
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b netstat -tlnp | grep 18789"
|
||||||
|
|
||||||
|
# 查看 Gateway 日志
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b tail -100 /tmp/openclaw/openclaw-*.log"
|
||||||
|
|
||||||
|
# 测试本地连接
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b curl -I http://127.0.0.1:18789"
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```bash
|
||||||
|
# 1. 杀死所有 Gateway 进程
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b pkill -9 -f 'openclaw-gateway'"
|
||||||
|
|
||||||
|
# 2. 启动新的 Gateway
|
||||||
|
ssh lazycat "lzc-docker exec -d 5f3bf33e090b bash -c 'openclaw gateway run > /tmp/gateway.log 2>&1 &'"
|
||||||
|
|
||||||
|
# 3. 等待启动
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 4. 验证状态
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b openclaw gateway status"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多个 OpenClaw TUI 实例运行(已修复 2026-02-16)
|
||||||
|
|
||||||
|
**现象**:
|
||||||
|
- 每次启动 OpenClaw TUI 前需要 `pkill -9 openclaw`
|
||||||
|
- 启动失败或端口冲突
|
||||||
|
- 多个 `openclaw-tui` 进程在后台运行
|
||||||
|
- 容器资源占用异常高
|
||||||
|
|
||||||
|
**诊断**:
|
||||||
|
```bash
|
||||||
|
# 检查运行中的 OpenClaw 进程
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep openclaw"
|
||||||
|
|
||||||
|
# 通常会看到多个 openclaw-tui 实例:
|
||||||
|
# PID 3041 - openclaw-tui (pts/0)
|
||||||
|
# PID 6338 - openclaw-tui (pts/1)
|
||||||
|
# PID 7223 - openclaw-tui (pts/2)
|
||||||
|
|
||||||
|
# 检查端口占用
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b netstat -tlnp | grep 18789"
|
||||||
|
```
|
||||||
|
|
||||||
|
**根本原因**:
|
||||||
|
- 每次运行 `openclaw tui` 都启动新进程
|
||||||
|
- 退出 TUI 时进程没有完全清理
|
||||||
|
- 多个实例同时运行导致资源竞争
|
||||||
|
|
||||||
|
**永久解决方案(已实施)**:
|
||||||
|
|
||||||
|
**1. 创建自动清理脚本**(容器中):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在容器中创建 /usr/local/bin/openclaw-clean
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b bash -c \"cat > /usr/local/bin/openclaw-clean << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# OpenClaw 清理并重启脚本
|
||||||
|
|
||||||
|
# 清理所有非 Tower 管理的 openclaw 进程
|
||||||
|
echo '🧹 清理旧的 OpenClaw 进程...'
|
||||||
|
pkill -9 -f 'openclaw-tui' || true
|
||||||
|
pkill -9 -f 'openclaw tui' || true
|
||||||
|
|
||||||
|
# 等待进程完全退出
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# 检查剩余进程
|
||||||
|
REMAINING=\\\$(ps aux | grep -E 'openclaw' | grep -v 'openclaw-gateway' | grep -v 'tower' | grep -v 'grep' | wc -l)
|
||||||
|
if [ \\\$REMAINING -gt 0 ]; then
|
||||||
|
echo '⚠️ 警告:还有 '\\\$REMAINING' 个 openclaw 进程'
|
||||||
|
else
|
||||||
|
echo '✅ 清理完成'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动 OpenClaw TUI
|
||||||
|
echo ''
|
||||||
|
echo '🦞 启动 OpenClaw TUI...'
|
||||||
|
exec openclaw tui
|
||||||
|
EOF
|
||||||
|
chmod +x /usr/local/bin/openclaw-clean\""
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 更新本地 openclaw-tui 脚本**:
|
||||||
|
|
||||||
|
修改 `~/bin/openclaw-tui` 的最后一行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 修改前
|
||||||
|
ssh -t lazycat "lzc-docker exec -it 5f3bf33e090b bash -c 'openclaw tui'"
|
||||||
|
|
||||||
|
# 修改后
|
||||||
|
ssh -t lazycat "lzc-docker exec -it 5f3bf33e090b openclaw-clean"
|
||||||
|
```
|
||||||
|
|
||||||
|
**修复效果**:
|
||||||
|
- ✅ 每次启动自动清理旧进程
|
||||||
|
- ✅ 不再需要手动 `pkill -9 openclaw`
|
||||||
|
- ✅ 避免多实例导致的资源浪费
|
||||||
|
- ✅ 一条命令 `openclaw-tui` 搞定所有
|
||||||
|
|
||||||
|
**使用方法**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 以前(需要手动清理)
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b bash -c 'pkill -9 openclaw && openclaw tui'"
|
||||||
|
|
||||||
|
# 现在(自动清理)
|
||||||
|
openclaw-tui # 一条命令搞定!
|
||||||
|
```
|
||||||
|
|
||||||
|
**手动清理**(如果需要):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 清理所有 openclaw-tui 进程
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b pkill -9 -f 'openclaw-tui'"
|
||||||
|
|
||||||
|
# 验证清理结果
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep openclaw | grep -v tower | grep -v openclaw-gateway"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 容器内存不足
|
||||||
|
|
||||||
|
**现象**:
|
||||||
|
- 容器内存使用率超过 90%
|
||||||
|
- OOM (Out of Memory) 错误
|
||||||
|
- 进程被 killed
|
||||||
|
|
||||||
|
**诊断**:
|
||||||
|
```bash
|
||||||
|
# 检查内存使用
|
||||||
|
ssh lazycat "lzc-docker stats --no-stream 5f3bf33e090b"
|
||||||
|
|
||||||
|
# 查看内存限制
|
||||||
|
ssh lazycat "lzc-docker inspect 5f3bf33e090b --format='{{.HostConfig.Memory}}'"
|
||||||
|
|
||||||
|
# 查看系统总内存
|
||||||
|
ssh lazycat "free -h"
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```bash
|
||||||
|
# 调整内存限制(如果当前限制过低)
|
||||||
|
# 注意:懒猫算力仓已设置为 30GB,一般不需要调整
|
||||||
|
|
||||||
|
# 如确需调整,使用以下命令
|
||||||
|
ssh lazycat "lzc-docker update 5f3bf33e090b --memory=30g --memory-swap=32g"
|
||||||
|
|
||||||
|
# 重启容器使配置生效
|
||||||
|
ssh lazycat "lzc-docker restart 5f3bf33e090b"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动重启失败
|
||||||
|
|
||||||
|
**现象**:
|
||||||
|
- systemd timer 未触发
|
||||||
|
- 重启脚本执行失败
|
||||||
|
- 日志显示 `lzc-docker: command not found`
|
||||||
|
|
||||||
|
**诊断**:
|
||||||
|
```bash
|
||||||
|
# 检查 timer 状态
|
||||||
|
ssh lazycat "systemctl status openclaw-restart.timer"
|
||||||
|
|
||||||
|
# 检查 service 状态
|
||||||
|
ssh lazycat "systemctl status openclaw-restart.service"
|
||||||
|
|
||||||
|
# 查看 service 日志
|
||||||
|
ssh lazycat "journalctl -u openclaw-restart.service -n 50"
|
||||||
|
|
||||||
|
# 查看脚本日志
|
||||||
|
ssh lazycat "tail -50 /var/log/openclaw-restart.log"
|
||||||
|
|
||||||
|
# 手动测试脚本
|
||||||
|
ssh lazycat "bash -x /root/restart-openclaw.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
问题通常是脚本中 `lzc-docker` 命令找不到(PATH 问题)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确认 lzc-docker 路径
|
||||||
|
ssh lazycat "which lzc-docker"
|
||||||
|
# 输出: /lzcsys/bin/lzc-docker
|
||||||
|
|
||||||
|
# 确保脚本使用完整路径
|
||||||
|
ssh lazycat "grep 'lzc-docker' /root/restart-openclaw.sh"
|
||||||
|
# 应该看到: /lzcsys/bin/lzc-docker
|
||||||
|
|
||||||
|
# 如果使用的是相对路径,需要修改
|
||||||
|
ssh lazycat "sed -i 's|lzc-docker|/lzcsys/bin/lzc-docker|g' /root/restart-openclaw.sh"
|
||||||
|
ssh lazycat "sed -i 's|lzc-docker|/lzcsys/bin/lzc-docker|g' /root/monitor-openclaw-zombies.sh"
|
||||||
|
|
||||||
|
# 重新加载 systemd 配置
|
||||||
|
ssh lazycat "systemctl daemon-reload"
|
||||||
|
|
||||||
|
# 测试执行
|
||||||
|
ssh lazycat "/root/restart-openclaw.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 定期健康检查
|
||||||
|
|
||||||
|
建议每天执行一次全面健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# OpenClaw 健康检查脚本
|
||||||
|
|
||||||
|
echo "🔍 OpenClaw 健康检查 - $(date)"
|
||||||
|
echo "================================"
|
||||||
|
|
||||||
|
# 容器状态
|
||||||
|
echo -e "\n📦 容器状态:"
|
||||||
|
ssh lazycat "lzc-docker ps --filter id=5f3bf33e090b --format 'Status: {{.Status}}'"
|
||||||
|
|
||||||
|
# PID 1 进程
|
||||||
|
echo -e "\n🏗️ PID 1 进程:"
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps -p 1 -o pid,ppid,cmd"
|
||||||
|
|
||||||
|
# 僵尸进程数
|
||||||
|
echo -e "\n👻 僵尸进程:"
|
||||||
|
ZOMBIE_COUNT=$(ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep -c 'Z'")
|
||||||
|
echo "僵尸进程数: $ZOMBIE_COUNT"
|
||||||
|
if [ $ZOMBIE_COUNT -gt 10 ]; then
|
||||||
|
echo "⚠️ 警告:僵尸进程较多,建议重启容器"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 资源使用
|
||||||
|
echo -e "\n💾 资源使用:"
|
||||||
|
ssh lazycat "lzc-docker stats --no-stream 5f3bf33e090b"
|
||||||
|
|
||||||
|
# Gateway 状态
|
||||||
|
echo -e "\n🔌 Gateway 状态:"
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b openclaw gateway status | grep 'RPC probe'"
|
||||||
|
|
||||||
|
# 系统负载
|
||||||
|
echo -e "\n📊 系统负载:"
|
||||||
|
ssh lazycat "uptime"
|
||||||
|
|
||||||
|
echo -e "\n================================"
|
||||||
|
echo "✅ 健康检查完成"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 日志管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看最近的错误日志
|
||||||
|
ssh lazycat "lzc-docker logs 5f3bf33e090b --since 1h 2>&1 | grep -i error"
|
||||||
|
|
||||||
|
# 查看 Tower 崩溃日志
|
||||||
|
ssh lazycat "lzc-docker logs 5f3bf33e090b 2>&1 | grep -i 'crashed\|failed'"
|
||||||
|
|
||||||
|
# 查看 OpenClaw 应用日志
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b tail -100 /tmp/openclaw/openclaw-$(date +%Y-%m-%d).log"
|
||||||
|
|
||||||
|
# 清理旧日志(保留最近7天)
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b find /tmp/openclaw -name '*.log' -mtime +7 -delete"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 备份与恢复
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 备份 OpenClaw 配置
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b tar czf /tmp/openclaw-config-backup-$(date +%Y%m%d).tar.gz -C /home/node/.openclaw ."
|
||||||
|
|
||||||
|
# 下载备份到本地
|
||||||
|
scp lazycat:/tmp/openclaw-config-backup-*.tar.gz ~/backups/
|
||||||
|
|
||||||
|
# 恢复配置
|
||||||
|
scp ~/backups/openclaw-config-backup-*.tar.gz lazycat:/tmp/
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b tar xzf /tmp/openclaw-config-backup-*.tar.gz -C /home/node/.openclaw"
|
||||||
|
ssh lazycat "lzc-docker restart 5f3bf33e090b"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 监控告警
|
||||||
|
|
||||||
|
建议设置以下监控指标:
|
||||||
|
|
||||||
|
- **僵尸进程数** > 50:触发告警,自动重启(已实现)
|
||||||
|
- **内存使用率** > 90%:触发告警
|
||||||
|
- **Gateway 离线时间** > 5分钟:触发告警
|
||||||
|
- **容器重启次数** > 3次/天:触发告警
|
||||||
|
|
||||||
|
### 5. 容器重建后的恢复清单
|
||||||
|
|
||||||
|
如果容器被重新创建(从镜像),需要重新应用以下修复:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装 tini
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b bash -c 'apt-get update -qq && apt-get install -y tini'"
|
||||||
|
|
||||||
|
# 2. 修改 entrypoint
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b sed -i 's|exec /usr/local/bin/tower|exec /usr/bin/tini -- /usr/local/bin/tower|g' /usr/local/bin/clawdbot-entrypoint.sh"
|
||||||
|
|
||||||
|
# 3. 重启容器
|
||||||
|
ssh lazycat "lzc-docker restart 5f3bf33e090b"
|
||||||
|
|
||||||
|
# 4. 验证
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps -p 1 -o cmd | grep tini"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### 相关文档
|
||||||
|
|
||||||
|
- OpenClaw 官方文档:https://docs.openclaw.ai/
|
||||||
|
- 故障排查指南:https://docs.openclaw.ai/troubleshooting
|
||||||
|
- Tini 项目:https://github.com/krallin/tini
|
||||||
|
|
||||||
|
### 联系信息
|
||||||
|
|
||||||
|
- 懒猫云支持:support@lazycat.cloud
|
||||||
|
- OpenClaw 社区:https://community.openclaw.ai/
|
||||||
|
|
||||||
|
### 版本历史
|
||||||
|
|
||||||
|
- 2026-02-16:
|
||||||
|
- 创建文档,记录 Tower 崩溃修复经验(使用 tini)
|
||||||
|
- 添加多 TUI 实例问题和 openclaw-clean 解决方案
|
||||||
|
- 2026-02-15:实施僵尸进程监控和自动重启
|
||||||
|
- 2026-02-14:调整容器资源限制为接近系统上限
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
|
||||||
|
### 常用命令速查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 连接 OpenClaw TUI
|
||||||
|
openclaw-tui
|
||||||
|
|
||||||
|
# 查看容器状态
|
||||||
|
ssh lazycat "lzc-docker ps | grep openclaw"
|
||||||
|
|
||||||
|
# 重启容器
|
||||||
|
ssh lazycat "lzc-docker restart 5f3bf33e090b"
|
||||||
|
|
||||||
|
# 查看僵尸进程数
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b ps aux | grep -c 'Z'"
|
||||||
|
|
||||||
|
# 检查 Gateway 状态
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b openclaw gateway status | grep 'RPC probe'"
|
||||||
|
|
||||||
|
# 查看资源使用
|
||||||
|
ssh lazycat "lzc-docker stats --no-stream 5f3bf33e090b"
|
||||||
|
|
||||||
|
# 查看定时任务
|
||||||
|
ssh lazycat "systemctl list-timers | grep openclaw"
|
||||||
|
|
||||||
|
# 清理多余的 OpenClaw TUI 进程
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b pkill -9 -f 'openclaw-tui'"
|
||||||
|
|
||||||
|
# 启动 OpenClaw(自动清理旧进程)
|
||||||
|
ssh lazycat "lzc-docker exec 5f3bf33e090b openclaw-clean"
|
||||||
|
|
||||||
|
# 全面健康检查
|
||||||
|
ssh lazycat "echo '=== 容器 ===' && lzc-docker ps | grep openclaw && echo '' && echo '=== 僵尸进程 ===' && lzc-docker exec 5f3bf33e090b ps aux | grep -c 'Z' && echo '' && echo '=== Gateway ===' && lzc-docker exec 5f3bf33e090b openclaw gateway status | grep 'RPC probe'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 故障处理速查
|
||||||
|
|
||||||
|
| 问题 | 快速解决 |
|
||||||
|
|------|----------|
|
||||||
|
| Tower 反复崩溃 | 参考"Tower 反复崩溃"章节,安装 tini |
|
||||||
|
| 多个 TUI 实例 | 使用 `openclaw-tui`(自动清理)或手动 `pkill -9 -f openclaw-tui` |
|
||||||
|
| Gateway 无响应 | `ssh lazycat "lzc-docker restart 5f3bf33e090b"` |
|
||||||
|
| 僵尸进程过多 | `ssh lazycat "lzc-docker restart 5f3bf33e090b"` |
|
||||||
|
| 内存不足 | 检查资源限制,重启容器 |
|
||||||
|
| 自动重启失败 | 检查脚本是否使用完整路径 `/lzcsys/bin/lzc-docker` |
|
||||||
8
plugins/openclaw-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/openclaw-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-plugin",
|
||||||
|
"description": "OpenClaw (龙虾) - Remote AI compute orchestration system. Dispatches requirements to Claude Code instances on remote machines, monitors execution, and aggregates results.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
342
plugins/openclaw-plugin/skills/SKILL.md
Normal file
342
plugins/openclaw-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
---
|
||||||
|
name: openclaw
|
||||||
|
description: OpenClaw (龙虾) - Remote AI compute orchestration system. Dispatches requirements to Claude Code instances on remote machines, monitors execution, and aggregates results.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenClaw - Remote AI Compute Orchestration
|
||||||
|
|
||||||
|
OpenClaw (龙虾) 是一个分布式 AI 算力调度系统,用于将需求/任务分发到不同机器上的 Claude Code 实例执行。
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 1. Ticket (工单)
|
||||||
|
每个需求 (Requirement) 或任务 (Task) 都是一个 Ticket,包含:
|
||||||
|
- **ID**: REQ-YYYYMMDD-XXXX 或 Task ID
|
||||||
|
- **Description**: 任务描述
|
||||||
|
- **Context**: 项目路径、技术栈、依赖
|
||||||
|
- **Priority**: 优先级 (high/medium/low)
|
||||||
|
- **Target**: 目标计算节点
|
||||||
|
|
||||||
|
### 2. Compute Node (算力节点)
|
||||||
|
运行 Claude Code 的开发机器:
|
||||||
|
- **Melbourne** (coolbuy-dev): 主开发机,全栈开发
|
||||||
|
- **Shanghai** (dev-box): 测试环境
|
||||||
|
- **Beijing** (lazycat): OpenClaw 本身所在的容器
|
||||||
|
|
||||||
|
### 3. Dispatcher (调度器)
|
||||||
|
OpenClaw 核心组件,负责:
|
||||||
|
- 接收 Ticket
|
||||||
|
- 选择合适的 Compute Node
|
||||||
|
- 生成 Claude Code 命令
|
||||||
|
- 通过 SSH 执行
|
||||||
|
- 监控进度
|
||||||
|
- 汇总结果
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 基本调用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式 1: 通过需求 ID
|
||||||
|
/openclaw dispatch REQ-20260216-0001
|
||||||
|
|
||||||
|
# 方式 2: 直接指定任务
|
||||||
|
/openclaw run "实现用户批量导入功能" --node melbourne --project new-ai-proj
|
||||||
|
|
||||||
|
# 方式 3: 使用 Swarm 模式
|
||||||
|
/openclaw swarm REQ-20260216-0001 --node melbourne
|
||||||
|
```
|
||||||
|
|
||||||
|
### 指定计算节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 自动选择 (根据负载和项目位置)
|
||||||
|
/openclaw dispatch REQ-20260216-0001 --auto
|
||||||
|
|
||||||
|
# 指定节点
|
||||||
|
/openclaw dispatch REQ-20260216-0001 --node melbourne
|
||||||
|
|
||||||
|
# 并行执行 (多个节点)
|
||||||
|
/openclaw dispatch REQ-20260216-0001 --nodes melbourne,shanghai --parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
### 监控执行状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有运行中的任务
|
||||||
|
/openclaw status
|
||||||
|
|
||||||
|
# 查看特定任务的日志
|
||||||
|
/openclaw logs REQ-20260216-0001
|
||||||
|
|
||||||
|
# 实时追踪任务进度
|
||||||
|
/openclaw tail REQ-20260216-0001
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
### 完整示例: 从 Feishu 到执行
|
||||||
|
|
||||||
|
**步骤 1: 用户在飞书发起请求**
|
||||||
|
```
|
||||||
|
@龙虾 执行 REQ-20260216-0001
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 2: Feishu Bot 解析命令**
|
||||||
|
- 提取需求 ID: REQ-20260216-0001
|
||||||
|
- 调用 OpenClaw skill
|
||||||
|
|
||||||
|
**步骤 3: OpenClaw 查询需求详情**
|
||||||
|
```bash
|
||||||
|
# 使用 ai-proj-prod MCP 获取需求信息
|
||||||
|
mcp__ai-proj-prod__find_requirement(displayId="REQ-20260216-0001")
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 4: OpenClaw 生成执行计划**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ticket_id": "REQ-20260216-0001",
|
||||||
|
"title": "AI Ticket 演示: 验证算力调度系统",
|
||||||
|
"type": "swarm",
|
||||||
|
"target_node": "melbourne",
|
||||||
|
"command": "/swarm start '实现 AI Ticket 从创建到算力调度的完整流程'",
|
||||||
|
"context": {
|
||||||
|
"project": "/Users/coolbuy-dev/coding/new-ai-proj",
|
||||||
|
"requirement_id": "REQ-20260216-0001",
|
||||||
|
"priority": "high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 5: SSH 执行命令**
|
||||||
|
```bash
|
||||||
|
ssh coolbuy-dev@melbourne \
|
||||||
|
"cd /Users/coolbuy-dev/coding/new-ai-proj && \
|
||||||
|
/opt/homebrew/bin/claude --dangerously-skip-permissions \
|
||||||
|
-p '/swarm start 实现 AI Ticket 从创建到算力调度的完整流程 \
|
||||||
|
--context requirement_id=REQ-20260216-0001'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 6: Claude Code 执行 Swarm 工作流**
|
||||||
|
- Architect Agent: 分析需求,设计方案
|
||||||
|
- Coder Agent: 实现代码
|
||||||
|
- Tester Agent: 编写和运行测试
|
||||||
|
- Reviewer Agent: 代码审查
|
||||||
|
- Deployer Agent: 部署到环境
|
||||||
|
|
||||||
|
**步骤 7: OpenClaw 监控和汇总**
|
||||||
|
- 实时获取执行日志
|
||||||
|
- 解析 Agent 切换和进度
|
||||||
|
- 检测完成或失败
|
||||||
|
|
||||||
|
**步骤 8: 报告结果**
|
||||||
|
```
|
||||||
|
【执行完成】REQ-20260216-0001
|
||||||
|
✅ 状态: 成功
|
||||||
|
⏱️ 耗时: 25 分钟
|
||||||
|
📝 修改: 12 个文件
|
||||||
|
✅ 测试: 32 个用例全部通过
|
||||||
|
🚀 部署: staging 环境已更新
|
||||||
|
|
||||||
|
详情: https://ai-proj.pipexerp.com/requirements/REQ-20260216-0001
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置文件
|
||||||
|
|
||||||
|
### openclaw.yaml
|
||||||
|
|
||||||
|
在 `~/.claude/openclaw/config.yaml` 配置节点信息:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nodes:
|
||||||
|
melbourne:
|
||||||
|
host: coolbuy-dev-macbook.local
|
||||||
|
user: coolbuy-dev
|
||||||
|
ssh_key: ~/.ssh/id_rsa
|
||||||
|
claude_path: /opt/homebrew/bin/claude
|
||||||
|
working_dirs:
|
||||||
|
- /Users/coolbuy-dev/coding/new-ai-proj
|
||||||
|
- /Users/coolbuy-dev/coding/coolbuy-paas
|
||||||
|
capabilities:
|
||||||
|
- go
|
||||||
|
- vue
|
||||||
|
- react
|
||||||
|
- ios
|
||||||
|
- android
|
||||||
|
max_concurrent: 3
|
||||||
|
|
||||||
|
shanghai:
|
||||||
|
host: dev-box.pipexerp.com
|
||||||
|
user: devops
|
||||||
|
ssh_key: ~/.ssh/id_rsa_ops
|
||||||
|
claude_path: /usr/local/bin/claude
|
||||||
|
working_dirs:
|
||||||
|
- /home/devops/projects
|
||||||
|
capabilities:
|
||||||
|
- testing
|
||||||
|
- deployment
|
||||||
|
max_concurrent: 2
|
||||||
|
|
||||||
|
routing_rules:
|
||||||
|
# 根据项目路径自动选择节点
|
||||||
|
- pattern: "*/new-ai-proj/*"
|
||||||
|
node: melbourne
|
||||||
|
|
||||||
|
- pattern: "*/coolbuy-*/*"
|
||||||
|
node: melbourne
|
||||||
|
|
||||||
|
# 根据需求类别路由
|
||||||
|
- category: testing
|
||||||
|
node: shanghai
|
||||||
|
|
||||||
|
- category: feature
|
||||||
|
node: melbourne
|
||||||
|
priority: prefer # 非强制
|
||||||
|
|
||||||
|
# 默认节点
|
||||||
|
default: melbourne
|
||||||
|
|
||||||
|
monitoring:
|
||||||
|
progress_interval: 30s # 每 30 秒检查一次进度
|
||||||
|
timeout: 2h # 2 小时超时
|
||||||
|
feishu_notifications: true
|
||||||
|
feishu_webhook: ${FEISHU_OPENCLAW_WEBHOOK}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 高级功能
|
||||||
|
|
||||||
|
### 1. 负载均衡
|
||||||
|
|
||||||
|
OpenClaw 自动根据节点负载分配任务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 自动选择最空闲的节点
|
||||||
|
/openclaw dispatch REQ-20260216-0001 --load-balance
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 任务队列
|
||||||
|
|
||||||
|
当节点繁忙时,任务进入队列:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看队列
|
||||||
|
/openclaw queue
|
||||||
|
|
||||||
|
# 输出:
|
||||||
|
# 队列中的任务:
|
||||||
|
# 1. [melbourne] REQ-20260216-0002 - 等待中 (前面 1 个任务)
|
||||||
|
# 2. [shanghai] Task-1234 - 等待中 (前面 0 个任务)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 结果缓存
|
||||||
|
|
||||||
|
已执行过的相同任务可以复用结果:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/openclaw dispatch REQ-20260216-0001 --use-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 故障恢复
|
||||||
|
|
||||||
|
任务失败时自动重试或切换节点:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
retry_policy:
|
||||||
|
max_attempts: 3
|
||||||
|
backoff: exponential # 1min, 2min, 4min
|
||||||
|
fallback_node: shanghai # 失败后切换到备用节点
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与其他 Skills 集成
|
||||||
|
|
||||||
|
### 与 agent-swarm 集成
|
||||||
|
```bash
|
||||||
|
# OpenClaw 调度 Swarm 工作流
|
||||||
|
/openclaw swarm REQ-20260216-0001 --node melbourne
|
||||||
|
```
|
||||||
|
|
||||||
|
### 与 ai-proj 集成
|
||||||
|
```bash
|
||||||
|
# 自动更新需求状态
|
||||||
|
# pending → in_progress → testing → completed
|
||||||
|
/openclaw dispatch REQ-20260216-0001 --sync-status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 与 feishu 集成
|
||||||
|
```bash
|
||||||
|
# 飞书群接收进度通知
|
||||||
|
# "【进行中】REQ-20260216-0001 - Coder Agent 正在实现后端 API (35%)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 与 ops-tools 集成
|
||||||
|
```bash
|
||||||
|
# 部署到服务器
|
||||||
|
/openclaw dispatch REQ-20260216-0001 --deploy staging
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令速查
|
||||||
|
|
||||||
|
| 命令 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `/openclaw dispatch <req-id>` | 调度需求到节点执行 |
|
||||||
|
| `/openclaw run "<task>"` | 直接执行任务 |
|
||||||
|
| `/openclaw swarm <req-id>` | 使用 Swarm 模式执行 |
|
||||||
|
| `/openclaw status` | 查看所有任务状态 |
|
||||||
|
| `/openclaw logs <req-id>` | 查看任务日志 |
|
||||||
|
| `/openclaw tail <req-id>` | 实时追踪任务 |
|
||||||
|
| `/openclaw queue` | 查看任务队列 |
|
||||||
|
| `/openclaw nodes` | 列出所有计算节点 |
|
||||||
|
| `/openclaw cancel <req-id>` | 取消任务 |
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
| 问题 | 原因 | 解决方案 |
|
||||||
|
|------|------|----------|
|
||||||
|
| SSH 连接失败 | 网络或认证问题 | 检查 `~/.ssh/config` 和密钥权限 |
|
||||||
|
| Claude 命令未找到 | Claude 未安装或路径错误 | 更新 `openclaw.yaml` 中的 `claude_path` |
|
||||||
|
| 任务卡住不动 | 超时或节点挂起 | 使用 `/openclaw cancel` 取消并重试 |
|
||||||
|
| Feishu 通知失败 | Webhook 配置错误 | 检查 `FEISHU_OPENCLAW_WEBHOOK` 环境变量 |
|
||||||
|
|
||||||
|
## 安全考虑
|
||||||
|
|
||||||
|
1. **SSH 密钥管理**: 使用专用密钥,定期轮换
|
||||||
|
2. **命令注入防护**: 所有参数都经过严格转义
|
||||||
|
3. **权限控制**: 限制 Claude Code 的 `--dangerously-skip-permissions` 使用
|
||||||
|
4. **日志审计**: 所有命令执行都记录在 `~/.claude/openclaw/audit.log`
|
||||||
|
|
||||||
|
## 实际应用场景
|
||||||
|
|
||||||
|
### 场景 1: 多项目并行开发
|
||||||
|
```bash
|
||||||
|
# 同时在不同机器上开发不同项目
|
||||||
|
/openclaw dispatch REQ-001 --node melbourne --project new-ai-proj &
|
||||||
|
/openclaw dispatch REQ-002 --node shanghai --project coolbuy-paas &
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2: 测试环境验证
|
||||||
|
```bash
|
||||||
|
# 开发在 Melbourne,测试在 Shanghai
|
||||||
|
/openclaw dispatch REQ-003 --node melbourne # 开发
|
||||||
|
sleep 1h # 等待开发完成
|
||||||
|
/openclaw dispatch REQ-003 --node shanghai --verify # 测试验证
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3: 紧急 Bug 修复
|
||||||
|
```bash
|
||||||
|
# 高优先级任务插队执行
|
||||||
|
/openclaw dispatch BUG-001 --priority urgent --node melbourne
|
||||||
|
```
|
||||||
|
|
||||||
|
## 未来扩展
|
||||||
|
|
||||||
|
- [ ] Web 控制面板: 可视化任务状态和节点负载
|
||||||
|
- [ ] 分布式追踪: Jaeger/OpenTelemetry 集成
|
||||||
|
- [ ] 成本统计: 记录每个任务的 token 使用量和费用
|
||||||
|
- [ ] 智能调度: 基于历史数据预测任务执行时间
|
||||||
|
- [ ] 多云支持: 支持 AWS/Azure/GCP 上的计算节点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**开发者**: AI 项目管理助手
|
||||||
|
**版本**: 1.0.0
|
||||||
|
**最后更新**: 2026-02-16
|
||||||
8
plugins/ops-servers-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/ops-servers-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "ops-servers-plugin",
|
||||||
|
"description": "Plugin for ops-servers",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
262
plugins/ops-servers-plugin/skills/SKILL.md
Normal file
262
plugins/ops-servers-plugin/skills/SKILL.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
---
|
||||||
|
name: ops-servers
|
||||||
|
description: 企业服务器管理。用于云服务器分组管理、系统监控、备份管理、故障排查。当用户提到云服务器、生产环境、腾讯云、阿里云相关任务时自动激活。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 企业服务器管理 Skill
|
||||||
|
|
||||||
|
> 家庭网络设备请使用 `ops-home` Skill
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 服务器清单
|
||||||
|
|
||||||
|
| 别名 | IP | 用户 | SSH 密钥 | 用途 | 配置 | ISP | 账号 |
|
||||||
|
|------|-----|------|----------|------|------|-----|------|
|
||||||
|
| prod-pipexerp | 192.144.137.14 | ubuntu | ~/.ssh/officialWebsite.pem | pipexerp 官网 | 2核2G 40G SSD | 腾讯云 | 北京对丝 |
|
||||||
|
| prod-metaBI | 192.144.174.87 | ubuntu | ~/.ssh/prod_meta.pem | Metabase BI 分析 | - | 腾讯云 | 北京欢乐宿 |
|
||||||
|
| moltbot | 124.223.196.74 | root | ~/.ssh/moltbot | Moltbot 服务 | - | 腾讯云 | - |
|
||||||
|
| lazycat | 100.115.52.119 (haiqing.heiyu.space) | root | 密码认证 (zhiyun2026) | AI/计算节点 | - | - | - |
|
||||||
|
|
||||||
|
### SSH 快捷连接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pipexerp 官网服务器
|
||||||
|
ssh prod-pipexerp
|
||||||
|
|
||||||
|
# Metabase BI 分析服务器
|
||||||
|
ssh prod-metaBI
|
||||||
|
|
||||||
|
# Moltbot 服务器
|
||||||
|
ssh moltbot
|
||||||
|
|
||||||
|
# Lazycat AI 计算节点(密码:zhiyun2026)
|
||||||
|
ssh root@haiqing.heiyu.space
|
||||||
|
# 或使用 IP
|
||||||
|
ssh root@100.115.52.119
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 服务器分组架构
|
||||||
|
|
||||||
|
采用 **环境 + 服务** 混合分组模式:
|
||||||
|
|
||||||
|
### 按环境分组
|
||||||
|
|
||||||
|
| 环境 | 前缀 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| prod | prod- | 生产环境 |
|
||||||
|
| staging | stg- | 预发布环境 |
|
||||||
|
| test | test- | 测试环境 |
|
||||||
|
| dev | dev- | 开发环境 |
|
||||||
|
|
||||||
|
### 按服务分组
|
||||||
|
|
||||||
|
| 服务组 | 包含服务 | 说明 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| web | Nginx, 前端静态资源 | 负载均衡、静态资源 |
|
||||||
|
| api | Go/Node 后端服务 | 业务 API |
|
||||||
|
| db | MySQL, PostgreSQL | 数据库 |
|
||||||
|
| cache | Redis | 缓存服务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用运维命令
|
||||||
|
|
||||||
|
### 系统状态检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一键查看系统概况
|
||||||
|
ssh prod-pipexerp "echo '=== 负载 ===' && uptime && echo && echo '=== 内存 ===' && free -h && echo && echo '=== 磁盘 ===' && df -h"
|
||||||
|
|
||||||
|
# 查看 CPU 使用最高的进程
|
||||||
|
ssh prod-pipexerp "ps aux --sort=-%cpu | head -10"
|
||||||
|
|
||||||
|
# 查看内存使用最高的进程
|
||||||
|
ssh prod-pipexerp "ps aux --sort=-%mem | head -10"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看运行中的容器
|
||||||
|
ssh prod-pipexerp "docker ps"
|
||||||
|
|
||||||
|
# 查看所有容器
|
||||||
|
ssh prod-pipexerp "docker ps -a"
|
||||||
|
|
||||||
|
# 查看容器日志
|
||||||
|
ssh prod-pipexerp "docker logs -f <container_name> --tail 100"
|
||||||
|
|
||||||
|
# 重启容器
|
||||||
|
ssh prod-pipexerp "docker restart <container_name>"
|
||||||
|
|
||||||
|
# 清理未使用的资源
|
||||||
|
ssh prod-pipexerp "docker system prune -af"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网络检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看端口监听
|
||||||
|
ssh prod-pipexerp "sudo netstat -tlnp"
|
||||||
|
|
||||||
|
# 检查防火墙状态
|
||||||
|
ssh prod-pipexerp "sudo ufw status"
|
||||||
|
|
||||||
|
# 测试端口连通性
|
||||||
|
nc -zv 192.144.137.14 80
|
||||||
|
nc -zv 192.144.137.14 443
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志查看
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nginx 错误日志
|
||||||
|
ssh prod-pipexerp "sudo tail -f /var/log/nginx/error.log"
|
||||||
|
|
||||||
|
# Nginx 访问日志
|
||||||
|
ssh prod-pipexerp "sudo tail -f /var/log/nginx/access.log"
|
||||||
|
|
||||||
|
# 系统日志
|
||||||
|
ssh prod-pipexerp "sudo tail -f /var/log/syslog"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批量操作
|
||||||
|
|
||||||
|
### 对多台服务器执行命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 定义服务器列表
|
||||||
|
SERVERS="prod-pipexerp"
|
||||||
|
|
||||||
|
# 批量检查状态
|
||||||
|
for host in $SERVERS; do
|
||||||
|
echo "=== $host ==="
|
||||||
|
ssh $host "uptime && free -h | head -2 && df -h / | tail -1"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 健康检查脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查所有服务器
|
||||||
|
for host in prod-pipexerp; do
|
||||||
|
echo "=== $host ==="
|
||||||
|
ssh $host "
|
||||||
|
echo '--- 负载 ---' && uptime
|
||||||
|
echo '--- 内存 ---' && free -h | head -2
|
||||||
|
echo '--- 磁盘 ---' && df -h / | tail -1
|
||||||
|
echo '--- Docker ---' && docker ps --format 'table {{.Names}}\t{{.Status}}' 2>/dev/null || echo 'N/A'
|
||||||
|
"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 备份管理
|
||||||
|
|
||||||
|
### 备份策略
|
||||||
|
|
||||||
|
| 备份类型 | 频率 | 保留时间 | 存储位置 |
|
||||||
|
|----------|------|----------|----------|
|
||||||
|
| 数据库全量 | 每日 02:00 | 7 天 | /backup/mysql/ |
|
||||||
|
| 配置文件 | 每日 03:00 | 30 天 | /backup/configs/ |
|
||||||
|
| 上传文件 | 每日 04:00 | 30 天 | /backup/uploads/ |
|
||||||
|
|
||||||
|
### 手动备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 备份 Nginx 配置
|
||||||
|
ssh prod-pipexerp "sudo tar -czf /tmp/nginx-\$(date +%Y%m%d).tar.gz /etc/nginx/"
|
||||||
|
|
||||||
|
# 下载备份到本地
|
||||||
|
scp prod-pipexerp:/tmp/nginx-*.tar.gz ./backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份清理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 清理 7 天前的备份
|
||||||
|
ssh prod-pipexerp "sudo find /backup/ -name '*.tar.gz' -mtime +7 -delete"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **服务无响应**
|
||||||
|
```bash
|
||||||
|
ssh prod-pipexerp "sudo systemctl status nginx"
|
||||||
|
ssh prod-pipexerp "sudo journalctl -u nginx --since '10 minutes ago'"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **磁盘空间不足**
|
||||||
|
```bash
|
||||||
|
ssh prod-pipexerp "df -h && sudo du -sh /* 2>/dev/null | sort -h | tail -10"
|
||||||
|
# 清理 Docker
|
||||||
|
ssh prod-pipexerp "docker system prune -af"
|
||||||
|
# 清理日志
|
||||||
|
ssh prod-pipexerp "sudo journalctl --vacuum-size=500M"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **内存不足**
|
||||||
|
```bash
|
||||||
|
ssh prod-pipexerp "free -h && ps aux --sort=-%mem | head -10"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **网站无法访问**
|
||||||
|
```bash
|
||||||
|
# 检查 Nginx
|
||||||
|
ssh prod-pipexerp "sudo systemctl status nginx"
|
||||||
|
# 检查端口
|
||||||
|
ssh prod-pipexerp "sudo netstat -tlnp | grep ':80\|:443'"
|
||||||
|
# 测试本地访问
|
||||||
|
ssh prod-pipexerp "curl -I http://localhost"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **SSL 证书问题**
|
||||||
|
```bash
|
||||||
|
# 检查证书到期时间
|
||||||
|
ssh prod-pipexerp "sudo openssl x509 -in /etc/nginx/ssl/cert.pem -noout -dates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 账号管理
|
||||||
|
|
||||||
|
### 系统用户
|
||||||
|
|
||||||
|
| 用户名 | 用途 | 权限 |
|
||||||
|
|--------|------|------|
|
||||||
|
| ubuntu | 默认管理用户 | sudo |
|
||||||
|
| deploy | 部署用户 | 部署相关 |
|
||||||
|
|
||||||
|
### 创建部署用户
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建用户
|
||||||
|
ssh prod-pipexerp "sudo useradd -m -s /bin/bash deploy"
|
||||||
|
|
||||||
|
# 配置 SSH 密钥
|
||||||
|
ssh prod-pipexerp "sudo mkdir -p /home/deploy/.ssh && sudo chmod 700 /home/deploy/.ssh"
|
||||||
|
ssh prod-pipexerp "sudo cp ~/.ssh/authorized_keys /home/deploy/.ssh/ && sudo chown -R deploy:deploy /home/deploy/.ssh"
|
||||||
|
|
||||||
|
# 配置 sudo 权限(无密码 docker 和 systemctl)
|
||||||
|
ssh prod-pipexerp "echo 'deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart *, /usr/bin/docker *' | sudo tee /etc/sudoers.d/deploy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全注意事项
|
||||||
|
|
||||||
|
- SSH 密钥文件权限必须是 600: `chmod 600 ~/.ssh/*.pem`
|
||||||
|
- 使用 `sudo` 执行需要 root 权限的命令
|
||||||
|
- 敏感操作前先确认服务器和目标
|
||||||
|
- 生产环境操作需要二次确认
|
||||||
|
- 定期更新系统和软件包
|
||||||
8
plugins/ops-tools-plugin/.claude-plugin/plugin.json
Normal file
8
plugins/ops-tools-plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "ops-tools-plugin",
|
||||||
|
"description": "Plugin for ops-tools",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "qiudl"
|
||||||
|
}
|
||||||
|
}
|
||||||
246
plugins/ops-tools-plugin/ai-proj-deploy.md
Normal file
246
plugins/ops-tools-plugin/ai-proj-deploy.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# AI-Proj 部署指南
|
||||||
|
|
||||||
|
**创建时间**: 2026-01-29 11:50:00 CST
|
||||||
|
**父技能**: ops-tools
|
||||||
|
|
||||||
|
## 环境概览
|
||||||
|
|
||||||
|
| 环境 | 服务器 | 域名 | 镜像标签 |
|
||||||
|
|------|--------|------|----------|
|
||||||
|
| 生产 | tools_ai_proj (152.136.104.251) | https://ai.pipexerp.com | `latest` |
|
||||||
|
| 测试 | singapore (43.134.28.147) | http://staging.ai.pipexerp.com | `test` |
|
||||||
|
|
||||||
|
## 镜像信息
|
||||||
|
|
||||||
|
| 服务 | 镜像 |
|
||||||
|
|------|------|
|
||||||
|
| 后端 | `saltthing123/ai-proj-backend` |
|
||||||
|
| 前端 | `saltthing123/ai-proj-frontend` |
|
||||||
|
|
||||||
|
## 标准部署流程
|
||||||
|
|
||||||
|
### 部署到测试环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/new-ai-proj
|
||||||
|
|
||||||
|
# 构建后端 test 镜像
|
||||||
|
docker buildx build --platform linux/amd64 -f backend/Dockerfile --target production \
|
||||||
|
-t saltthing123/ai-proj-backend:test --push backend/
|
||||||
|
|
||||||
|
# 构建前端 test 镜像
|
||||||
|
docker buildx build --platform linux/amd64 -f frontend/Dockerfile.prod --target production \
|
||||||
|
--build-arg REACT_APP_API_URL=https://staging.ai.pipexerp.com/api/v1 \
|
||||||
|
--build-arg REACT_APP_API_BASE_URL=https://staging.ai.pipexerp.com/api/v1 \
|
||||||
|
--build-arg REACT_APP_ENV=staging \
|
||||||
|
-t saltthing123/ai-proj-frontend:test --push frontend/
|
||||||
|
|
||||||
|
# 部署到测试服务器
|
||||||
|
ssh singapore "cd /opt/ai-project-staging && sudo docker-compose pull && sudo docker-compose up -d"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部署到生产环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/new-ai-proj
|
||||||
|
|
||||||
|
# 构建后端 latest 镜像
|
||||||
|
docker buildx build --platform linux/amd64 -f backend/Dockerfile --target production \
|
||||||
|
-t saltthing123/ai-proj-backend:latest --push backend/
|
||||||
|
|
||||||
|
# 构建前端 latest 镜像
|
||||||
|
docker buildx build --platform linux/amd64 -f frontend/Dockerfile.prod --target production \
|
||||||
|
--build-arg REACT_APP_API_URL=https://ai.pipexerp.com/api/v1 \
|
||||||
|
--build-arg REACT_APP_API_BASE_URL=https://ai.pipexerp.com/api/v1 \
|
||||||
|
--build-arg REACT_APP_ENV=production \
|
||||||
|
-t saltthing123/ai-proj-frontend:latest --push frontend/
|
||||||
|
|
||||||
|
# 部署到生产服务器
|
||||||
|
ssh tools_ai_proj "cd /opt/ai-project && \
|
||||||
|
docker compose -f deploy/tencent-cloud/docker-compose.dockerhub.yml pull && \
|
||||||
|
docker compose -f deploy/tencent-cloud/docker-compose.dockerhub.yml up -d"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新加坡服务器 Build(备选方案)
|
||||||
|
|
||||||
|
本地网络慢时,在新加坡服务器构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端
|
||||||
|
ssh singapore "cd ~/projects/new-ai-proj && git pull && \
|
||||||
|
docker build --platform linux/amd64 -f backend/Dockerfile --target production \
|
||||||
|
-t saltthing123/ai-proj-backend:latest ./backend && \
|
||||||
|
docker push saltthing123/ai-proj-backend:latest"
|
||||||
|
|
||||||
|
# 前端(生产)
|
||||||
|
ssh singapore "cd ~/projects/new-ai-proj && \
|
||||||
|
docker build --platform linux/amd64 -f frontend/Dockerfile.prod --target production \
|
||||||
|
--build-arg REACT_APP_API_URL=https://ai.pipexerp.com/api/v1 \
|
||||||
|
--build-arg REACT_APP_API_BASE_URL=https://ai.pipexerp.com/api/v1 \
|
||||||
|
--build-arg REACT_APP_ENV=production \
|
||||||
|
-t saltthing123/ai-proj-frontend:latest ./frontend && \
|
||||||
|
docker push saltthing123/ai-proj-frontend:latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自动部署(Webhook)
|
||||||
|
|
||||||
|
**状态**: 已启用 (2026-01-16)
|
||||||
|
|
||||||
|
```
|
||||||
|
git push main → Gitea webhook → Jenkins ai-proj → 生产自动部署
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看容器状态
|
||||||
|
ssh tools_ai_proj "docker ps --format 'table {{.Names}}\t{{.Status}}'"
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
ssh tools_ai_proj "docker logs -f ai_backend_prod --tail 100"
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
ssh tools_ai_proj "docker restart ai_backend_prod"
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
curl -s https://ai.pipexerp.com/api/v1/health | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试环境管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看状态
|
||||||
|
ssh singapore "sudo docker-compose -f /opt/ai-project-staging/docker-compose.yml ps"
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
ssh singapore "sudo docker logs -f ai_backend_staging --tail 100"
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
ssh singapore "curl -s -H 'Host: staging.ai.pipexerp.com' http://127.0.0.1/api/v1/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Volumes 配置
|
||||||
|
|
||||||
|
**重要**: 数据卷必须标记为 `external: true`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
postgres_prod_data:
|
||||||
|
external: true
|
||||||
|
name: ai-project_postgres_prod_data
|
||||||
|
redis_prod_data:
|
||||||
|
external: true
|
||||||
|
name: ai-project_redis_prod_data
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行迁移
|
||||||
|
ssh tools_ai_proj "cd /opt/ai-project/backend/migrations && \
|
||||||
|
for file in \$(ls *.sql | grep -v _down.sql | sort); do \
|
||||||
|
docker exec -i ai_postgres_prod psql -U ai_prod_user -d ai_project_prod < \"\$file\"; \
|
||||||
|
done"
|
||||||
|
|
||||||
|
# 备份
|
||||||
|
ssh tools_ai_proj "docker exec ai_postgres_prod pg_dump -U ai_prod_user ai_project_prod > /tmp/backup.sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 用户管理
|
||||||
|
|
||||||
|
> **重要**: 密码哈希必须使用 bcrypt **cost 12**,这是后端 `utils/password.go` 中的 `DefaultCost` 值。
|
||||||
|
|
||||||
|
### 创建用户完整流程
|
||||||
|
|
||||||
|
由于 bcrypt 哈希包含 `$` 字符会被 shell 解释,必须使用文件传输方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 生成密码哈希(在有 Go 环境的机器上执行)
|
||||||
|
cd /path/to/new-ai-proj/backend
|
||||||
|
cat > /tmp/genhash.go << 'EOF'
|
||||||
|
package main
|
||||||
|
import ("fmt"; "golang.org/x/crypto/bcrypt")
|
||||||
|
func main() {
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("用户密码"), 12)
|
||||||
|
fmt.Println(string(hash))
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
HASH=$(go run /tmp/genhash.go)
|
||||||
|
echo "Generated hash: $HASH"
|
||||||
|
|
||||||
|
# 2. 创建 SQL 文件
|
||||||
|
cat > /tmp/create_user.sql << EOF
|
||||||
|
INSERT INTO users (username, email, password_hash, user_type, role, status, created_at, updated_at)
|
||||||
|
VALUES ('newuser', 'newuser@example.com', '$HASH', 'system', 'admin', 'active', NOW(), NOW());
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 3. 传输并执行
|
||||||
|
scp /tmp/create_user.sql tools_ai_proj:/tmp/
|
||||||
|
ssh tools_ai_proj "docker cp /tmp/create_user.sql ai_postgres_prod:/tmp/ && \
|
||||||
|
docker exec ai_postgres_prod psql -U ai_prod_user -d ai_project_prod -f /tmp/create_user.sql"
|
||||||
|
|
||||||
|
# 4. 验证
|
||||||
|
curl -s -X POST "https://ai.pipexerp.com/api/v1/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"newuser","password":"用户密码"}' | jq '.success'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户角色
|
||||||
|
|
||||||
|
| user_type | role | 权限 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| system | admin | 系统管理员 |
|
||||||
|
| system | user | 系统用户 |
|
||||||
|
| tenant | admin | 租户管理员 |
|
||||||
|
| tenant | user | 租户用户 |
|
||||||
|
|
||||||
|
### 重置密码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成新哈希并更新
|
||||||
|
cd /path/to/new-ai-proj/backend
|
||||||
|
HASH=$(go run -e 'package main; import ("fmt";"golang.org/x/crypto/bcrypt"); func main() { h,_:=bcrypt.GenerateFromPassword([]byte("新密码"),12); fmt.Println(string(h)) }' 2>/dev/null || cat > /tmp/h.go << 'E'
|
||||||
|
package main
|
||||||
|
import ("fmt";"golang.org/x/crypto/bcrypt")
|
||||||
|
func main() { h,_:=bcrypt.GenerateFromPassword([]byte("新密码"),12); fmt.Println(string(h)) }
|
||||||
|
E
|
||||||
|
go run /tmp/h.go)
|
||||||
|
|
||||||
|
cat > /tmp/reset.sql << EOF
|
||||||
|
UPDATE users SET password_hash = '$HASH' WHERE username = 'targetuser';
|
||||||
|
EOF
|
||||||
|
|
||||||
|
scp /tmp/reset.sql tools_ai_proj:/tmp/
|
||||||
|
ssh tools_ai_proj "docker cp /tmp/reset.sql ai_postgres_prod:/tmp/ && \
|
||||||
|
docker exec ai_postgres_prod psql -U ai_prod_user -d ai_project_prod -f /tmp/reset.sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见错误
|
||||||
|
|
||||||
|
| 问题 | 原因 | 解决 |
|
||||||
|
|------|------|------|
|
||||||
|
| 登录失败 | bcrypt cost 不是 12 | 用 Go 重新生成 cost 12 的哈希 |
|
||||||
|
| 哈希被截断 | shell 解释了 $ 符号 | 使用文件传输方式 |
|
||||||
|
|
||||||
|
### 已创建的系统用户
|
||||||
|
|
||||||
|
| 用户名 | 邮箱 | user_type | role | 创建时间 |
|
||||||
|
|--------|------|-----------|------|----------|
|
||||||
|
| qiudl | qiudl@zhiyuncai.com | system | admin | - |
|
||||||
|
| jiaxiang | jiaxiang@joylodging.com | system | admin | 2026-01 |
|
||||||
|
| haiqing | haiqing@joylodging.com | system | admin | 2026-02 |
|
||||||
|
|
||||||
|
> 注意:密码信息不在文档中记录,如需重置请使用上述重置密码流程
|
||||||
|
|
||||||
|
## 前端构建注意事项
|
||||||
|
|
||||||
|
必须同时设置两个 URL 变量:
|
||||||
|
- `REACT_APP_API_URL`
|
||||||
|
- `REACT_APP_API_BASE_URL`
|
||||||
|
|
||||||
|
否则会使用 `.env.production` 中的生产 URL。
|
||||||
|
|
||||||
|
验证镜像中的 URL:
|
||||||
|
```bash
|
||||||
|
docker exec <container> sh -c 'grep -oE "https://[a-zA-Z0-9.-]*pipexerp[a-zA-Z0-9./-]*" /usr/share/nginx/html/static/js/main*.js | sort | uniq -c'
|
||||||
|
```
|
||||||
100
plugins/ops-tools-plugin/coolbuy-deploy.md
Normal file
100
plugins/ops-tools-plugin/coolbuy-deploy.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Coolbuy-PaaS 部署指南
|
||||||
|
|
||||||
|
**创建时间**: 2026-01-29 11:50:00 CST
|
||||||
|
**父技能**: ops-tools
|
||||||
|
|
||||||
|
## 仓库信息
|
||||||
|
|
||||||
|
| 仓库 | 地址 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| coolbuy-paas | git@gitea.pipexerp.com:pipexerp/coolbuy-paas.git | 租户业务系统 |
|
||||||
|
| coolbuy-platform | git@gitea.pipexerp.com:pipexerp/coolbuy-platform.git | 平台管理端 |
|
||||||
|
| coolbuy-legacy | git@gitea.pipexerp.com:pipexerp/coolbuy-legacy.git | 遗留项目 |
|
||||||
|
|
||||||
|
## 镜像信息
|
||||||
|
|
||||||
|
| 服务 | 镜像 | Dockerfile |
|
||||||
|
|------|------|------------|
|
||||||
|
| Auth | saltthing123/coolbuy-paas-auth | auth-service/Dockerfile |
|
||||||
|
| Foundation | saltthing123/coolbuy-paas-foundation | foundation-service/Dockerfile |
|
||||||
|
| ERP | saltthing123/coolbuy-paas-erp | erp-service/Dockerfile |
|
||||||
|
| Web | saltthing123/coolbuy-paas-web | web/Dockerfile |
|
||||||
|
|
||||||
|
## 生产环境
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 服务器 IP | 39.106.88.83 |
|
||||||
|
| 架构 | AMD64 |
|
||||||
|
| 部署目录 | /opt/coolbuy-paas |
|
||||||
|
| Web 端口 | 8888 |
|
||||||
|
|
||||||
|
## 部署流程(本地构建 + Jenkins 部署)
|
||||||
|
|
||||||
|
### 步骤 1: 本地构建并推送
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/coolbuy-paas
|
||||||
|
|
||||||
|
# 构建单个服务(AMD64 架构)
|
||||||
|
docker buildx build --platform linux/amd64 -t saltthing123/coolbuy-paas-web:latest ./web --push
|
||||||
|
|
||||||
|
# 构建所有服务
|
||||||
|
./scripts/build-and-push.sh --push --platform linux/amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2: 触发 Jenkins 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.config/devops/credentials.env
|
||||||
|
|
||||||
|
# 部署到生产
|
||||||
|
curl -X POST "$JENKINS_URL/job/coolbuy-paas/buildWithParameters" \
|
||||||
|
-u "$JENKINS_USER:$JENKINS_TOKEN" \
|
||||||
|
--data "ACTION=deploy-prod&IMAGE_TAG=latest"
|
||||||
|
|
||||||
|
# 部署到测试
|
||||||
|
curl -X POST "$JENKINS_URL/job/coolbuy-paas/buildWithParameters" \
|
||||||
|
-u "$JENKINS_USER:$JENKINS_TOKEN" \
|
||||||
|
--data "ACTION=deploy-test&IMAGE_TAG=latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一键部署命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/coolbuy-paas && \
|
||||||
|
docker buildx build --platform linux/amd64 -t saltthing123/coolbuy-paas-web:latest ./web --push && \
|
||||||
|
source ~/.config/devops/credentials.env && \
|
||||||
|
curl -X POST "$JENKINS_URL/job/coolbuy-paas/buildWithParameters" \
|
||||||
|
-u "$JENKINS_USER:$JENKINS_TOKEN" \
|
||||||
|
--data "ACTION=deploy-prod&IMAGE_TAG=latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 查看构建状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.config/devops/credentials.env
|
||||||
|
|
||||||
|
# 构建状态
|
||||||
|
curl -s "$JENKINS_URL/job/coolbuy-paas/lastBuild/api/json" \
|
||||||
|
-u "$JENKINS_USER:$JENKINS_TOKEN" | jq '.result, .building'
|
||||||
|
|
||||||
|
# 构建日志
|
||||||
|
curl -s "$JENKINS_URL/job/coolbuy-paas/lastBuild/consoleText" \
|
||||||
|
-u "$JENKINS_USER:$JENKINS_TOKEN" | tail -50
|
||||||
|
```
|
||||||
|
|
||||||
|
## 检查镜像架构
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地镜像
|
||||||
|
docker inspect saltthing123/coolbuy-paas-web:latest | grep Architecture
|
||||||
|
|
||||||
|
# DockerHub 镜像
|
||||||
|
docker manifest inspect saltthing123/coolbuy-paas-web:latest | grep architecture
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重要提醒
|
||||||
|
|
||||||
|
- 生产服务器为 AMD64 架构,必须使用 `--platform linux/amd64`
|
||||||
|
- 禁止在 Jenkins 服务器构建镜像,所有镜像本地构建后推送到 DockerHub
|
||||||
837
plugins/ops-tools-plugin/db-backup.md
Normal file
837
plugins/ops-tools-plugin/db-backup.md
Normal file
@@ -0,0 +1,837 @@
|
|||||||
|
# 数据库备份与恢复 Skill
|
||||||
|
|
||||||
|
**父技能**: ops-tools
|
||||||
|
**适用范围**: 全局(所有项目数据库)
|
||||||
|
**创建时间**: 2026-01-15 07:30:00 ACDT
|
||||||
|
**最后更新**: 2026-02-02
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技能概述
|
||||||
|
|
||||||
|
全局数据库备份技能,适用于所有项目的 PostgreSQL 数据库。涵盖迁移前备份、自动备份、数据恢复和灾难恢复策略。
|
||||||
|
|
||||||
|
**核心原则**:
|
||||||
|
- ⚠️ **任何数据库迁移操作前必须先备份**
|
||||||
|
- 保留策略:最近 7 天 + 每月 1 个永久备份
|
||||||
|
- 存储位置:服务器本地 `/backup/` 目录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库清单
|
||||||
|
|
||||||
|
| 数据库 | 服务器 | 容器 | 用途 | 备份路径 |
|
||||||
|
|--------|--------|------|------|----------|
|
||||||
|
| ai_project_prod | tools_ai_proj | ai_postgres_prod | AI-Proj 生产 | /backup/ai-project/database/ |
|
||||||
|
| ai_project_staging | singapore | ai_postgres_staging | AI-Proj 测试 | /backup/ai-project-staging/ |
|
||||||
|
| coolbuy_prod | coolbuy-dev | postgres | Coolbuy 3.0 | /backup/coolbuy/ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 迁移前快速备份(必读)
|
||||||
|
|
||||||
|
> **重要**:执行任何 `UPDATE`、`DELETE`、`ALTER`、数据迁移等操作前,**必须先执行备份**。
|
||||||
|
|
||||||
|
### 一键备份命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# AI-Proj 生产数据库 - 迁移前备份
|
||||||
|
ssh tools_ai_proj 'REASON="pre_migration_$(date +%Y%m%d_%H%M%S)" && \
|
||||||
|
docker exec ai_postgres_prod pg_dump -U ai_prod_user -Fc ai_project_prod \
|
||||||
|
> /backup/ai-project/database/ai_project_${REASON}.dump && \
|
||||||
|
echo "✓ 备份完成: /backup/ai-project/database/ai_project_${REASON}.dump"'
|
||||||
|
|
||||||
|
# AI-Proj 测试数据库 - 迁移前备份
|
||||||
|
ssh singapore 'REASON="pre_migration_$(date +%Y%m%d_%H%M%S)" && \
|
||||||
|
sudo docker exec ai_postgres_staging pg_dump -U ai_staging_user -Fc ai_project_staging \
|
||||||
|
> /backup/ai-project-staging/ai_project_staging_${REASON}.dump && \
|
||||||
|
echo "✓ 备份完成"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 带原因的备份(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 指定备份原因,方便追溯
|
||||||
|
ssh tools_ai_proj 'REASON="migrate_project_165_to_167" && \
|
||||||
|
docker exec ai_postgres_prod pg_dump -U ai_prod_user -Fc ai_project_prod \
|
||||||
|
> /backup/ai-project/database/ai_project_$(date +%Y%m%d_%H%M%S)_${REASON}.dump && \
|
||||||
|
ls -lh /backup/ai-project/database/ | tail -3'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份后验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 验证备份文件
|
||||||
|
ssh tools_ai_proj 'ls -lh /backup/ai-project/database/ | tail -5'
|
||||||
|
|
||||||
|
# 检查备份文件大小(应该 > 10MB)
|
||||||
|
ssh tools_ai_proj 'stat --printf="%s bytes\n" /backup/ai-project/database/ai_project_*.dump | tail -1'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速恢复命令
|
||||||
|
|
||||||
|
### 从最新备份恢复
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 找到最新备份
|
||||||
|
ssh tools_ai_proj 'ls -lt /backup/ai-project/database/*.dump | head -3'
|
||||||
|
|
||||||
|
# 2. 恢复(使用 pg_restore)
|
||||||
|
ssh tools_ai_proj 'BACKUP_FILE="/backup/ai-project/database/ai_project_XXXXXXXX.dump" && \
|
||||||
|
docker stop ai_backend_prod && \
|
||||||
|
docker exec ai_postgres_prod pg_restore -U ai_prod_user -d ai_project_prod --clean --if-exists -Fc "$BACKUP_FILE" && \
|
||||||
|
docker start ai_backend_prod && \
|
||||||
|
echo "✓ 恢复完成"'
|
||||||
|
|
||||||
|
# 3. 验证
|
||||||
|
curl -s https://ai.pipexerp.com/api/v1/health | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 恢复到特定时间点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 列出所有备份,找到目标时间点
|
||||||
|
ssh tools_ai_proj 'ls -lht /backup/ai-project/database/*.dump'
|
||||||
|
|
||||||
|
# 恢复指定备份
|
||||||
|
ssh tools_ai_proj 'docker exec ai_postgres_prod pg_restore \
|
||||||
|
-U ai_prod_user -d ai_project_prod --clean --if-exists -Fc \
|
||||||
|
/backup/ai-project/database/ai_project_20260202_180000_migrate_project_165_to_167.dump'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 保留策略
|
||||||
|
|
||||||
|
### 策略说明
|
||||||
|
|
||||||
|
| 类型 | 保留时间 | 清理规则 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 每日备份 | 7 天 | 超过 7 天自动删除 |
|
||||||
|
| 月度备份 | 永久 | 每月 1 号的备份永久保留 |
|
||||||
|
| 迁移前备份 | 30 天 | 带 `pre_migration` 标记的保留 30 天 |
|
||||||
|
|
||||||
|
### 自动清理脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /opt/scripts/cleanup-backups.sh
|
||||||
|
#!/bin/bash
|
||||||
|
BACKUP_DIR="/backup/ai-project/database"
|
||||||
|
|
||||||
|
# 删除超过 7 天的每日备份(保留月度备份)
|
||||||
|
find "$BACKUP_DIR" -name "*.dump" -mtime +7 ! -name "*_01_*" -delete
|
||||||
|
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 ! -name "*_01_*" -delete
|
||||||
|
|
||||||
|
# 删除超过 30 天的迁移前备份
|
||||||
|
find "$BACKUP_DIR" -name "*pre_migration*" -mtime +30 -delete
|
||||||
|
|
||||||
|
echo "$(date): Cleanup completed" >> /var/log/backup-cleanup.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron 配置
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# 每天凌晨 3 点清理旧备份
|
||||||
|
0 3 * * * /opt/scripts/cleanup-backups.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
|
||||||
|
| 操作 | 命令 |
|
||||||
|
|------|------|
|
||||||
|
| 手动执行备份 | `ssh tools_ai_proj "/opt/ai-project/deploy/scripts/backup-database.sh"` |
|
||||||
|
| 查看本地备份 | `ssh tools_ai_proj "ls -lh /backup/ai-project/database/"` |
|
||||||
|
| 查看备份日志 | `ssh tools_ai_proj "tail -f /var/log/ai-project-backup.log"` |
|
||||||
|
| 触发 OSS 同步 | `ssh tools_ai_proj "/opt/ai-project/deploy/scripts/backup-to-oss.sh"` |
|
||||||
|
| 列出 OSS 备份 | `ssh tools_ai_proj "ossutil ls oss://fnos2026/ai-project/backups/ --config-file ~/.ossutilconfig"` |
|
||||||
|
| 下载最新备份 | `ssh tools_ai_proj "ossutil cp oss://fnos2026/ai-project/backups/latest.sql.gz /tmp/ --config-file ~/.ossutilconfig"` |
|
||||||
|
| 验证备份完整性 | `ssh tools_ai_proj "gzip -t /backup/ai-project/database/latest.sql.gz"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 备份架构
|
||||||
|
|
||||||
|
### 双层备份策略
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ AI-Proj 生产服务器 │
|
||||||
|
│ (tools_ai_proj: 152.136.104.251) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ PostgreSQL 数据库 (ai_postgres_prod) │
|
||||||
|
│ │ │
|
||||||
|
│ │ 每天 02:00 (Cron) │
|
||||||
|
│ ▼ │
|
||||||
|
│ 本地备份 (/backup/ai-project/database/) │
|
||||||
|
│ │ - gzip 压缩 │
|
||||||
|
│ │ - 30 天保留 │
|
||||||
|
│ │ - 完整性验证 │
|
||||||
|
│ │ - 符号链接 (latest.sql.gz) │
|
||||||
|
│ │ │
|
||||||
|
│ │ 每天 02:30 (Cron) │
|
||||||
|
│ ▼ │
|
||||||
|
│ OSS 同步 (backup-to-oss.sh) │
|
||||||
|
│ │ │
|
||||||
|
└────────────┼─────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 互联网 (623 KB/s)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 阿里云对象存储 (OSS) │
|
||||||
|
│ 北京区域 │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Bucket: fnos2026 │
|
||||||
|
│ 路径: /ai-project/backups/ │
|
||||||
|
│ │
|
||||||
|
│ ├── YYYYMMDD/ │
|
||||||
|
│ │ └── ai_project_YYYYMMDD_HHMMSS.sql.gz │
|
||||||
|
│ └── latest.sql.gz (最新备份) │
|
||||||
|
│ │
|
||||||
|
│ ✅ 异地容灾 (99.9% 可用性) │
|
||||||
|
│ ✅ 30 天自动清理 │
|
||||||
|
│ ✅ 成本: ~¥0.25/月 │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份时间表
|
||||||
|
|
||||||
|
| 时间 | 操作 | 脚本 | 日志文件 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 02:00 | 本地数据库备份 | `/opt/ai-project/deploy/scripts/backup-database.sh` | `/var/log/ai-project-backup.log` |
|
||||||
|
| 02:30 | OSS 异地同步 | `/opt/ai-project/deploy/scripts/backup-to-oss.sh` | `/var/log/ai-project-oss-sync.log` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自动备份配置
|
||||||
|
|
||||||
|
### 本地备份
|
||||||
|
|
||||||
|
**脚本位置**: `/opt/ai-project/deploy/scripts/backup-database.sh`
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
- ✅ PostgreSQL pg_dump 完整备份
|
||||||
|
- ✅ gzip 压缩
|
||||||
|
- ✅ 按日期目录组织
|
||||||
|
- ✅ 30 天自动清理
|
||||||
|
- ✅ 备份完整性验证
|
||||||
|
- ✅ 符号链接指向最新备份
|
||||||
|
|
||||||
|
**Cron 配置**:
|
||||||
|
```cron
|
||||||
|
0 2 * * * /opt/ai-project/deploy/scripts/backup-database.sh >> /var/log/ai-project-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
**手动执行**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "/opt/ai-project/deploy/scripts/backup-database.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
**查看日志**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "tail -f /var/log/ai-project-backup.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
**备份目录结构**:
|
||||||
|
```
|
||||||
|
/backup/ai-project/database/
|
||||||
|
├── 20260115/
|
||||||
|
│ ├── ai_project_20260115_020001.sql.gz (13M)
|
||||||
|
│ └── ai_project_20260115_120000.sql.gz (13M)
|
||||||
|
├── 20260116/
|
||||||
|
│ └── ai_project_20260116_020001.sql.gz (13M)
|
||||||
|
└── latest.sql.gz -> 20260116/ai_project_20260116_020001.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阿里云 OSS 异地备份
|
||||||
|
|
||||||
|
**配置时间**: 2026-01-15 01:12:00 CST
|
||||||
|
**首次同步**: 2026-01-15 02:30:01 CST
|
||||||
|
|
||||||
|
### OSS 配置信息
|
||||||
|
|
||||||
|
| 配置项 | 值 |
|
||||||
|
|--------|-----|
|
||||||
|
| Endpoint | oss-cn-beijing.aliyuncs.com |
|
||||||
|
| Bucket | fnos2026 |
|
||||||
|
| 存储路径 | oss://fnos2026/ai-project/backups/ |
|
||||||
|
| 保留策略 | 30 天自动清理 |
|
||||||
|
| 预计成本 | ~¥0.25/月 |
|
||||||
|
|
||||||
|
### 凭据配置
|
||||||
|
|
||||||
|
**存储位置**: `~/.config/devops/credentials.env` (权限 600)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com"
|
||||||
|
OSS_BUCKET="fnos2026"
|
||||||
|
OSS_ACCESS_KEY_ID="LTAI5tEARCztp3Bj3FUYd9rh"
|
||||||
|
OSS_ACCESS_KEY_SECRET="RSvwURFo2cgF1krSgeriyrAUIqQyGE"
|
||||||
|
```
|
||||||
|
|
||||||
|
**加载凭据**:
|
||||||
|
```bash
|
||||||
|
source ~/.config/devops/credentials.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### ossutil 工具
|
||||||
|
|
||||||
|
**版本**: v1.7.15
|
||||||
|
**安装位置**: `/usr/local/bin/ossutil`
|
||||||
|
**安装时间**: 2026-01-15 00:45:00 CST
|
||||||
|
|
||||||
|
**安装步骤**:
|
||||||
|
```bash
|
||||||
|
wget https://gosspublic.alicdn.com/ossutil/1.7.15/ossutil64
|
||||||
|
sudo mv ossutil64 /usr/local/bin/ossutil
|
||||||
|
sudo chmod +x /usr/local/bin/ossutil
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```bash
|
||||||
|
source ~/.config/devops/credentials.env
|
||||||
|
ossutil config -e ${OSS_ENDPOINT} \
|
||||||
|
-i ${OSS_ACCESS_KEY_ID} \
|
||||||
|
-k ${OSS_ACCESS_KEY_SECRET} \
|
||||||
|
-L CH \
|
||||||
|
--config-file ~/.ossutilconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试连接**:
|
||||||
|
```bash
|
||||||
|
ossutil ls oss://${OSS_BUCKET}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动同步脚本
|
||||||
|
|
||||||
|
**脚本位置**: `/opt/ai-project/deploy/scripts/backup-to-oss.sh`
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
- ✅ 同步当天备份目录到 OSS
|
||||||
|
- ✅ 上传 latest.sql.gz
|
||||||
|
- ✅ 自动清理 30 天前的旧备份
|
||||||
|
- ✅ 备份统计报告
|
||||||
|
- ✅ 彩色日志输出
|
||||||
|
|
||||||
|
**Cron 配置**:
|
||||||
|
```cron
|
||||||
|
30 2 * * * /opt/ai-project/deploy/scripts/backup-to-oss.sh >> /var/log/ai-project-oss-sync.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
**手动执行**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "/opt/ai-project/deploy/scripts/backup-to-oss.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
**查看日志**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "tail -f /var/log/ai-project-oss-sync.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用 OSS 操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 加载凭据
|
||||||
|
source ~/.config/devops/credentials.env
|
||||||
|
|
||||||
|
# 列出所有备份文件
|
||||||
|
ssh tools_ai_proj "ossutil ls oss://${OSS_BUCKET}/ai-project/backups/ -r --config-file ~/.ossutilconfig"
|
||||||
|
|
||||||
|
# 查看备份统计
|
||||||
|
ssh tools_ai_proj "ossutil du oss://${OSS_BUCKET}/ai-project/backups/ --config-file ~/.ossutilconfig"
|
||||||
|
|
||||||
|
# 下载特定日期的备份
|
||||||
|
ssh tools_ai_proj "ossutil cp oss://${OSS_BUCKET}/ai-project/backups/20260115/ai_project_20260115_*.sql.gz /tmp/ --config-file ~/.ossutilconfig"
|
||||||
|
|
||||||
|
# 下载最新备份
|
||||||
|
ssh tools_ai_proj "ossutil cp oss://${OSS_BUCKET}/ai-project/backups/latest.sql.gz /tmp/ --config-file ~/.ossutilconfig"
|
||||||
|
|
||||||
|
# 查看备份文件详情
|
||||||
|
ssh tools_ai_proj "ossutil stat oss://${OSS_BUCKET}/ai-project/backups/latest.sql.gz --config-file ~/.ossutilconfig"
|
||||||
|
|
||||||
|
# 手动清理特定日期的备份
|
||||||
|
ssh tools_ai_proj "ossutil rm oss://${OSS_BUCKET}/ai-project/backups/20260101/ -r -f --config-file ~/.ossutilconfig"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 验证最新备份是否上传成功
|
||||||
|
ssh tools_ai_proj "ossutil stat oss://${OSS_BUCKET}/ai-project/backups/latest.sql.gz --config-file ~/.ossutilconfig"
|
||||||
|
|
||||||
|
# 下载并测试备份完整性
|
||||||
|
ssh tools_ai_proj "
|
||||||
|
ossutil cp oss://${OSS_BUCKET}/ai-project/backups/latest.sql.gz /tmp/test_restore.sql.gz --config-file ~/.ossutilconfig
|
||||||
|
gzip -t /tmp/test_restore.sql.gz && echo '✓ 备份文件完整' || echo '✗ 备份文件损坏'
|
||||||
|
rm /tmp/test_restore.sql.gz
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 手动备份
|
||||||
|
|
||||||
|
### 完整备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 连接到生产服务器
|
||||||
|
ssh tools_ai_proj
|
||||||
|
|
||||||
|
# 导出数据库
|
||||||
|
docker exec ai_postgres_prod pg_dump -U ai_prod_user ai_project_prod \
|
||||||
|
--no-owner --no-acl --clean --if-exists \
|
||||||
|
> /tmp/ai_project_backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
# 压缩备份
|
||||||
|
gzip /tmp/ai_project_backup_*.sql
|
||||||
|
|
||||||
|
# 验证备份完整性
|
||||||
|
gzip -t /tmp/ai_project_backup_*.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### 下载到本地
|
||||||
|
|
||||||
|
**直接下载** (如果网络良好):
|
||||||
|
```bash
|
||||||
|
scp tools_ai_proj:/tmp/ai_project_backup_*.sql.gz /tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
**通过跳板机优化传输** (高延迟环境):
|
||||||
|
```bash
|
||||||
|
# 使用新加坡跳板机中转(澳洲 → 新加坡 → 腾讯云)
|
||||||
|
scp tools_ai_proj:/tmp/ai_project_backup_*.sql.gz singapore:/tmp/
|
||||||
|
scp singapore:/tmp/ai_project_backup_*.sql.gz /tmp/
|
||||||
|
|
||||||
|
# 清理跳板机临时文件
|
||||||
|
ssh singapore "rm /tmp/ai_project_backup_*.sql.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库恢复
|
||||||
|
|
||||||
|
### 场景 1: 从 OSS 备份恢复(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 从 OSS 下载最新备份
|
||||||
|
ssh tools_ai_proj "
|
||||||
|
source ~/.config/devops/credentials.env
|
||||||
|
ossutil cp oss://fnos2026/ai-project/backups/latest.sql.gz /tmp/restore.sql.gz --config-file ~/.ossutilconfig -f
|
||||||
|
"
|
||||||
|
|
||||||
|
# 2. 验证文件完整性
|
||||||
|
ssh tools_ai_proj "gzip -t /tmp/restore.sql.gz"
|
||||||
|
|
||||||
|
# 3. 停止后端服务
|
||||||
|
ssh tools_ai_proj "docker stop ai_backend_prod"
|
||||||
|
|
||||||
|
# 4. 恢复数据库
|
||||||
|
ssh tools_ai_proj "
|
||||||
|
gunzip -c /tmp/restore.sql.gz | \
|
||||||
|
docker exec -i ai_postgres_prod psql -U ai_prod_user ai_project_prod
|
||||||
|
"
|
||||||
|
|
||||||
|
# 5. 启动后端服务
|
||||||
|
ssh tools_ai_proj "docker start ai_backend_prod"
|
||||||
|
|
||||||
|
# 6. 验证服务
|
||||||
|
curl -s https://ai.pipexerp.com/api/v1/health | jq .
|
||||||
|
|
||||||
|
# 7. 清理临时文件
|
||||||
|
ssh tools_ai_proj "rm /tmp/restore.sql.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2: 从本地备份恢复
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj
|
||||||
|
|
||||||
|
# 停止后端服务
|
||||||
|
docker stop ai_backend_prod
|
||||||
|
|
||||||
|
# 恢复数据库
|
||||||
|
gunzip -c /backup/ai-project/database/latest.sql.gz | \
|
||||||
|
docker exec -i ai_postgres_prod psql -U ai_prod_user ai_project_prod
|
||||||
|
|
||||||
|
# 启动后端服务
|
||||||
|
docker start ai_backend_prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3: 从本地开发环境恢复到生产(完整重建)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 本地导出
|
||||||
|
pg_dump -U donglinlai ai_project_local \
|
||||||
|
--no-owner --no-acl --clean --if-exists \
|
||||||
|
--exclude-table=audit_logs \
|
||||||
|
> /tmp/ai_project_clean.sql
|
||||||
|
|
||||||
|
# 2. 压缩
|
||||||
|
gzip /tmp/ai_project_clean.sql
|
||||||
|
|
||||||
|
# 3. 通过新加坡跳板机传输(优化高延迟)
|
||||||
|
scp /tmp/ai_project_clean.sql.gz singapore:/tmp/
|
||||||
|
ssh singapore "scp /tmp/ai_project_clean.sql.gz tools_ai_proj:/tmp/"
|
||||||
|
|
||||||
|
# 4. 生产环境恢复
|
||||||
|
ssh tools_ai_proj
|
||||||
|
|
||||||
|
# 停止后端服务
|
||||||
|
docker stop ai_backend_prod
|
||||||
|
|
||||||
|
# 完全重建数据库(避免依赖冲突)
|
||||||
|
docker exec ai_postgres_prod psql -U ai_prod_user postgres \
|
||||||
|
-c 'DROP DATABASE IF EXISTS ai_project_prod;'
|
||||||
|
|
||||||
|
docker exec ai_postgres_prod psql -U ai_prod_user postgres \
|
||||||
|
-c 'CREATE DATABASE ai_project_prod OWNER ai_prod_user;'
|
||||||
|
|
||||||
|
# 恢复数据
|
||||||
|
gunzip -c /tmp/ai_project_clean.sql.gz | \
|
||||||
|
docker exec -i ai_postgres_prod psql -U ai_prod_user ai_project_prod
|
||||||
|
|
||||||
|
# 创建可能缺失的表
|
||||||
|
docker exec -i ai_postgres_prod psql -U ai_prod_user ai_project_prod << 'EOF'
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER,
|
||||||
|
action VARCHAR(100),
|
||||||
|
resource_type VARCHAR(100),
|
||||||
|
resource_id VARCHAR(100),
|
||||||
|
details TEXT,
|
||||||
|
ip_address VARCHAR(50),
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 运行数据库迁移(如果有)
|
||||||
|
cd /opt/ai-project/backend/migrations
|
||||||
|
for file in $(ls *.sql | grep -v _down.sql | sort); do
|
||||||
|
echo "Running migration: $file"
|
||||||
|
docker exec -i ai_postgres_prod psql -U ai_prod_user ai_project_prod < "$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 启动后端服务
|
||||||
|
docker start ai_backend_prod
|
||||||
|
|
||||||
|
# 清理临时文件
|
||||||
|
rm /tmp/ai_project_clean.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### Docker Volumes 安全配置
|
||||||
|
|
||||||
|
**关键规则**: 所有生产数据卷必须配置为 `external: true`
|
||||||
|
|
||||||
|
**正确配置** (`deploy/tencent-cloud/docker-compose.dockerhub.yml`):
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
postgres_prod_data:
|
||||||
|
external: true
|
||||||
|
name: ai-project_postgres_prod_data
|
||||||
|
redis_prod_data:
|
||||||
|
external: true
|
||||||
|
name: ai-project_redis_prod_data
|
||||||
|
```
|
||||||
|
|
||||||
|
**危险配置** (会被 `docker compose down` 删除):
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
postgres_prod_data:
|
||||||
|
redis_prod_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "docker volume ls | grep ai-project"
|
||||||
|
# 应该看到:
|
||||||
|
# ai-project_postgres_prod_data
|
||||||
|
# ai-project_redis_prod_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份策略
|
||||||
|
|
||||||
|
1. **每日自动备份** - 使用 cron 定时任务
|
||||||
|
2. **双层备份** - 本地 + 阿里云 OSS
|
||||||
|
3. **定期验证** - 每周测试备份恢复流程
|
||||||
|
4. **保留策略** - 30 天自动清理
|
||||||
|
|
||||||
|
### pg_dump 最佳参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 跨服务器迁移
|
||||||
|
pg_dump --no-owner --no-acl --clean --if-exists --exclude-table=<problem_table>
|
||||||
|
|
||||||
|
# 参数说明:
|
||||||
|
# --no-owner 不恢复对象所有者(避免用户名冲突)
|
||||||
|
# --no-acl 不恢复访问权限(避免权限问题)
|
||||||
|
# --clean 包含 DROP 语句(完全替换)
|
||||||
|
# --if-exists DROP 前检查存在(避免错误)
|
||||||
|
# --exclude-table 排除问题表(如有 JSON 格式问题的表)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据恢复检查清单
|
||||||
|
|
||||||
|
在执行恢复前,务必检查以下项目:
|
||||||
|
|
||||||
|
- [ ] 确认备份文件完整性(gzip -t 验证)
|
||||||
|
- [ ] 停止相关应用服务(避免数据不一致)
|
||||||
|
- [ ] 完全重建数据库(DROP + CREATE,避免依赖冲突)
|
||||||
|
- [ ] 恢复后创建缺失的表(如被排除的表)
|
||||||
|
- [ ] 运行数据库迁移(确保表结构最新)
|
||||||
|
- [ ] 验证数据完整性(检查关键表行数)
|
||||||
|
- [ ] 测试应用功能(登录、关键业务流程)
|
||||||
|
- [ ] 清理临时文件(备份文件、SQL 文件)
|
||||||
|
|
||||||
|
### 网络传输优化
|
||||||
|
|
||||||
|
**场景**: 跨地域高延迟环境(如澳洲 → 腾讯云)
|
||||||
|
|
||||||
|
**问题**: 直连延迟 370ms+,大文件传输极慢
|
||||||
|
|
||||||
|
**方案**: 使用地理位置中间的跳板机
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 直连(慢): 澳洲 → 腾讯云 (370ms+)
|
||||||
|
scp file.gz tools_ai_proj:/tmp/
|
||||||
|
|
||||||
|
# 优化(快): 澳洲 → 新加坡 → 腾讯云
|
||||||
|
scp file.gz singapore:/tmp/
|
||||||
|
ssh singapore "scp /tmp/file.gz tools_ai_proj:/tmp/"
|
||||||
|
```
|
||||||
|
|
||||||
|
**新加坡跳板机信息**:
|
||||||
|
- 别名: singapore
|
||||||
|
- IP: 43.134.28.147
|
||||||
|
- 用户: ubuntu
|
||||||
|
- SSH Key: ~/.ssh/singpore.pem
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 监控与告警
|
||||||
|
|
||||||
|
### 每周检查清单
|
||||||
|
|
||||||
|
**建议执行频率**: 每周一次
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 检查本地备份
|
||||||
|
ssh tools_ai_proj "ls -lh /backup/ai-project/database/$(date +%Y%m%d)/"
|
||||||
|
|
||||||
|
# 2. 检查 OSS 备份
|
||||||
|
ssh tools_ai_proj "ossutil stat oss://fnos2026/ai-project/backups/latest.sql.gz --config-file ~/.ossutilconfig"
|
||||||
|
|
||||||
|
# 3. 检查 cron 日志
|
||||||
|
ssh tools_ai_proj "tail -20 /var/log/ai-project-backup.log"
|
||||||
|
ssh tools_ai_proj "tail -20 /var/log/ai-project-oss-sync.log"
|
||||||
|
|
||||||
|
# 4. 验证备份大小(应该在 10-20M 范围)
|
||||||
|
ssh tools_ai_proj "du -sh /backup/ai-project/database/$(date +%Y%m%d)/"
|
||||||
|
|
||||||
|
# 5. 测试备份完整性
|
||||||
|
ssh tools_ai_proj "
|
||||||
|
gzip -t /backup/ai-project/database/latest.sql.gz && \
|
||||||
|
echo '✓ 本地备份完整' || echo '✗ 本地备份损坏'
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份失败排查
|
||||||
|
|
||||||
|
如果备份或同步失败:
|
||||||
|
|
||||||
|
1. **检查磁盘空间**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "df -h"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **检查 PostgreSQL 容器状态**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "docker ps | grep postgres"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **检查 ossutil 配置**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "cat ~/.ossutilconfig"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **测试 OSS 连接**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "ossutil ls oss://fnos2026/ --config-file ~/.ossutilconfig"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **手动运行脚本查看详细错误**:
|
||||||
|
```bash
|
||||||
|
ssh tools_ai_proj "/opt/ai-project/deploy/scripts/backup-database.sh"
|
||||||
|
ssh tools_ai_proj "/opt/ai-project/deploy/scripts/backup-to-oss.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 成本估算
|
||||||
|
|
||||||
|
**基于当前数据量** (13MB/天):
|
||||||
|
|
||||||
|
| 项目 | 计算 | 月成本 |
|
||||||
|
|------|------|--------|
|
||||||
|
| OSS 存储 | 13MB × 30天 = 390MB × ¥0.12/GB | ¥0.05 |
|
||||||
|
| OSS 流量 | 13MB × 30天 = 390MB × ¥0.50/GB | ¥0.20 |
|
||||||
|
| **总计** | | **¥0.25** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 故障案例
|
||||||
|
|
||||||
|
### 2026-01-15: 生产数据库丢失事件
|
||||||
|
|
||||||
|
**事件时间**: 2026-01-15 00:00:00 - 00:46:00 CST
|
||||||
|
|
||||||
|
**事件**: Jenkins 部署时 `docker compose down` 删除了非 external volumes
|
||||||
|
|
||||||
|
**影响**: 生产数据库完全清空,所有用户无法登录
|
||||||
|
|
||||||
|
**恢复过程**:
|
||||||
|
1. 从本地开发环境导出完整数据(41 用户、54 项目、4,722 任务)
|
||||||
|
2. 使用新加坡跳板机优化传输(解决 370ms+ 延迟)
|
||||||
|
3. 完全重建数据库避免依赖冲突
|
||||||
|
4. 重置所有管理员密码
|
||||||
|
|
||||||
|
**恢复完成时间**: 2026-01-15 00:46:00 CST
|
||||||
|
|
||||||
|
**预防措施**:
|
||||||
|
1. ✅ 所有数据卷标记为 `external: true` (完成时间: 2026-01-15 00:46:00)
|
||||||
|
2. ✅ Jenkinsfile 添加自动数据库迁移 (完成时间: 2026-01-15 00:46:00)
|
||||||
|
3. ✅ 临时禁用 webhook 自动部署 (完成时间: 2026-01-15 00:46:00)
|
||||||
|
4. ✅ 配置自动备份策略(本地 + OSS 双层备份)(完成时间: 2026-01-15 02:30:46)
|
||||||
|
|
||||||
|
**详细记录**: 见思源笔记 `devops/运维记录/2026-01-15 AI-Proj生产数据库恢复记录`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用户管理相关
|
||||||
|
|
||||||
|
数据库用户管理(创建用户、重置密码)请参考:
|
||||||
|
- **ops-tools/SKILL.md** - "AI-Proj 用户管理" 章节
|
||||||
|
- **ai-proj-deploy.md** - "用户管理" 章节
|
||||||
|
|
||||||
|
**关键注意事项**:
|
||||||
|
- 密码哈希使用 bcrypt **cost 12**(后端 `utils/password.go` 的 `DefaultCost`)
|
||||||
|
- 由于 `$` 字符问题,SQL 必须通过文件传输方式执行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关资源
|
||||||
|
|
||||||
|
- **父技能**: ops-tools/skill.md
|
||||||
|
- **备份脚本**: `/opt/ai-project/deploy/scripts/backup-database.sh`
|
||||||
|
- **OSS 同步脚本**: `/opt/ai-project/deploy/scripts/backup-to-oss.sh`
|
||||||
|
- **凭据文件**: `~/.config/devops/credentials.env`
|
||||||
|
- **SiYuan 笔记**: `devops/运维记录/2026-01-15 AI-Proj生产数据库恢复记录`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库迁移标准流程
|
||||||
|
|
||||||
|
> **强制要求**:任何数据库迁移操作必须遵循以下流程。
|
||||||
|
|
||||||
|
### 迁移检查清单
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 数据库迁移标准流程 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. 迁移前备份 ⬅️ 必须 │
|
||||||
|
│ ssh tools_ai_proj 'docker exec ai_postgres_prod \ │
|
||||||
|
│ pg_dump -U ai_prod_user -Fc ai_project_prod \ │
|
||||||
|
│ > /backup/ai-project/database/pre_migration.dump' │
|
||||||
|
│ │
|
||||||
|
│ 2. 验证备份文件 │
|
||||||
|
│ ssh tools_ai_proj 'ls -lh /backup/.../pre_migration.dump'│
|
||||||
|
│ │
|
||||||
|
│ 3. 记录迁移前状态 │
|
||||||
|
│ SELECT COUNT(*) FROM <table>; │
|
||||||
|
│ │
|
||||||
|
│ 4. 执行迁移(使用事务) │
|
||||||
|
│ BEGIN; ... COMMIT; │
|
||||||
|
│ │
|
||||||
|
│ 5. 验证迁移结果 │
|
||||||
|
│ SELECT COUNT(*) FROM <table>; │
|
||||||
|
│ │
|
||||||
|
│ 6. 如有问题,恢复备份 │
|
||||||
|
│ pg_restore -Fc pre_migration.dump │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移 SQL 模板
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ============================================
|
||||||
|
-- 迁移脚本模板
|
||||||
|
-- 执行前请先备份!
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 迁移前统计
|
||||||
|
\echo '=== 迁移前统计 ==='
|
||||||
|
SELECT 'table_name' as info, COUNT(*) as count FROM table_name WHERE condition;
|
||||||
|
|
||||||
|
-- 执行迁移
|
||||||
|
\echo '=== 执行迁移 ==='
|
||||||
|
UPDATE table_name SET column = new_value WHERE condition;
|
||||||
|
|
||||||
|
-- 迁移后统计
|
||||||
|
\echo '=== 迁移后统计 ==='
|
||||||
|
SELECT 'table_name' as info, COUNT(*) as count FROM table_name WHERE condition;
|
||||||
|
|
||||||
|
-- 确认无误后提交
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
\echo '=== 迁移完成 ==='
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移失败回滚
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 停止后端服务
|
||||||
|
ssh tools_ai_proj 'docker stop ai_backend_prod'
|
||||||
|
|
||||||
|
# 2. 恢复备份
|
||||||
|
ssh tools_ai_proj 'docker exec ai_postgres_prod pg_restore \
|
||||||
|
-U ai_prod_user -d ai_project_prod --clean --if-exists -Fc \
|
||||||
|
/backup/ai-project/database/ai_project_XXXXXXXX_pre_migration.dump'
|
||||||
|
|
||||||
|
# 3. 启动后端服务
|
||||||
|
ssh tools_ai_proj 'docker start ai_backend_prod'
|
||||||
|
|
||||||
|
# 4. 验证服务
|
||||||
|
curl -s https://ai.pipexerp.com/api/v1/health | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2.0 | 2026-02-02 | 升级为全局技能:新增迁移前备份流程、多数据库支持、7天+月度保留策略、快速恢复命令 |
|
||||||
|
| 1.0 | 2026-01-15 | 初始版本:AI-Proj 备份与 OSS 同步 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档创建时间**: 2026-01-15 07:30:00 ACDT
|
||||||
|
**最后更新时间**: 2026-02-02
|
||||||
|
**文档状态**: ✅ 正常运行
|
||||||
41
plugins/ops-tools-plugin/deploy-check.sh
Executable file
41
plugins/ops-tools-plugin/deploy-check.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 部署状态检查脚本
|
||||||
|
# 用法: ./deploy-check.sh [ai-proj|pipeXerp]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TOOLS_SERVER="root@101.200.136.200"
|
||||||
|
TOOLS_KEY="~/.ssh/tools.pem"
|
||||||
|
JOB_NAME="${1:-ai-proj}"
|
||||||
|
|
||||||
|
echo "======================================"
|
||||||
|
echo "Jenkins Job: $JOB_NAME"
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
ssh -i $TOOLS_KEY -o ConnectTimeout=5 $TOOLS_SERVER << EOF
|
||||||
|
echo "--- 最近 5 次构建 ---"
|
||||||
|
ls -lt /var/lib/jenkins/jobs/$JOB_NAME/builds/ 2>/dev/null | head -6
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 最近成功构建 ---"
|
||||||
|
if [ -L /var/lib/jenkins/jobs/$JOB_NAME/builds/lastSuccessfulBuild ]; then
|
||||||
|
BUILD_NUM=\$(readlink /var/lib/jenkins/jobs/$JOB_NAME/builds/lastSuccessfulBuild)
|
||||||
|
echo "Build #\$BUILD_NUM"
|
||||||
|
if [ -f "/var/lib/jenkins/jobs/$JOB_NAME/builds/\$BUILD_NUM/log" ]; then
|
||||||
|
echo "构建时间: \$(stat -c %y /var/lib/jenkins/jobs/$JOB_NAME/builds/\$BUILD_NUM/log 2>/dev/null || stat -f %Sm /var/lib/jenkins/jobs/$JOB_NAME/builds/\$BUILD_NUM/log)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "无成功构建记录"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 最近失败构建 ---"
|
||||||
|
if [ -L /var/lib/jenkins/jobs/$JOB_NAME/builds/lastFailedBuild ]; then
|
||||||
|
BUILD_NUM=\$(readlink /var/lib/jenkins/jobs/$JOB_NAME/builds/lastFailedBuild)
|
||||||
|
echo "Build #\$BUILD_NUM"
|
||||||
|
echo "错误日志(最后 20 行):"
|
||||||
|
tail -20 /var/lib/jenkins/jobs/$JOB_NAME/builds/\$BUILD_NUM/log 2>/dev/null || echo "无法读取日志"
|
||||||
|
else
|
||||||
|
echo "无失败构建记录"
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user